Compare commits

...
Author SHA1 Message Date
Gergo Magyar 0796e1e68c chore: bump version to 1.3.10 and add CHANGELOG
Add CHANGELOG.md with release notes for v1.3.10 covering MCP transport
security hardening, dual-framing compatibility, lazy CLI loading, and
bug fixes from recent PRs.
2026-03-07 08:04:55 +00:00
ShockangandGergo Magyar 9d5ec5d19a Improve MCP startup compatibility and lazy-load CLI commands (#207)
* Fix MCP startup transport compatibility

* Preserve CLI flags in MCP startup fix

* Harden MCP transport error handling

* Harden transport security and improve type safety

Transport hardening:
- Add MAX_BUFFER_SIZE (10 MB) cap to prevent OOM from oversized
  Content-Length or unbounded newline-delimited input
- Replace recursive readNewlineMessage with iterative loop to prevent
  stack overflow from consecutive empty lines
- Tighten looksLikeContentLength to require 14+ bytes before matching
- Add closed-state guard and error handling to send()
- Simplify processReadBuffer loop to break on error
- Fix loose equality (==) to strict (===)
- Widen constructor param types to ReadableStream/WritableStream

Type safety:
- Constrain createLazyAction generics so export name is validated
  against the module's actual exports at compile time
- Use proper type guard instead of lint suppression
- Fix test tsconfig type errors

Regression tests for all hardening fixes (13 tests passing).

---------

Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-03-07 07:47:09 +00:00
abhigyanpatwariandClaude Opus 4.6 4de40e4011 chore: update AI context files with inline imperative instructions
Regenerated CLAUDE.md and AGENTS.md using gitnexus@1.3.9 which replaces
the old skill-router format with inline imperative instructions (PR #190).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 15:12:00 +05:30
Gergő Magyar 20e8c52028 Merge pull request #144 from magyargergo/fix/lru-cache-zero-max-crash
fix: guard createASTCache against zero maxSize to prevent LRU cache crash
2026-03-06 09:22:17 +00:00
Gary Magyar 2868da5ddb Merge remote-tracking branch 'origin/main' into fix/lru-cache-zero-max-crash 2026-03-06 09:02:50 +00:00
Abhigyan PatwariandClaude Opus 4.6 3db47f7ee5 fix(ingestion): align CALLS edge sourceId with node ID format (#194)
findEnclosingFunctionId generated IDs without :startLine suffix,
but node creation includes it. This caused every CALLS edge to
reference a non-existent source node, making the process detector
find 0 entry points and produce 0 execution flows.

Bumps to 1.3.9.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:57:20 +05:30
Abhigyan PatwariandClaude Opus 4.6 821871cec1 chore: bump version to 1.3.8 (#193)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:23:01 +05:30
Abhigyan PatwariandClaude Opus 4.6 f9a54cd588 fix(cli): force-exit after analyze to prevent KuzuDB hang (#192)
KuzuDB's native module holds open handles that prevent Node.js from
exiting cleanly. Previously only force-exited when embeddings were used
(for ONNX Runtime segfault workaround), but the same issue affects all
analyze runs. Now always calls process.exit(0) after completion.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:17:41 +05:30
Abhigyan PatwariandClaude Opus 4.6 8c6b064d18 chore: bump version to 1.3.7 (#191)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:58:35 +05:30
Abhigyan PatwariandClaude Opus 4.6 84ef6524bc feat(ai-context): replace skill router with inline imperative instructions (#190)
CLAUDE.md and AGENTS.md now contain direct enforcement instructions instead
of a passive skill router table. Based on Vercel eval data showing skills
are skipped 56% of the time, and industry research on effective AGENTS.md
patterns from 2,500+ repos.

Key changes:
- Always/When/Never three-tier boundary structure
- RFC 2119 language (MUST, NEVER) for critical rules
- Exact tool commands with parameters inline
- Self-check checklist forcing model to verify its own work
- ~77 lines, well within the <150 line adherence threshold

Skills are still installed as bonus depth for Claude Code's skill system.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 12:47:23 +05:30
abhigyanpatwariandClaude Opus 4.6 5674b2201d feat: merge Laravel route detection (PR #133), revert unwanted doc changes
Merged PR #133 which adds AST-based Laravel Route::* extraction.
Reverted AGENTS.md, CLAUDE.md, and README.md to preserve current config,
crypto warning, Discord link, and correct language support count (12,
including Kotlin/Swift).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:20:27 +05:30
abhigyanpatwari 6915a9350b Merge branch 'pr-133' 2026-03-03 00:19:44 +05:30
Gary Magyar 76e0e5a35a fix: guard createASTCache against zero maxSize to prevent LRU cache crash
When a repo has no parseable files (e.g., unsupported languages or all
files filtered out), chunks.reduce returns 0, causing createASTCache(0)
to pass max:0 to LRUCache which throws TypeError. This clamps maxSize
to at least 1 and adds a progress message when no parseable files exist.
2026-03-02 08:47:20 +00:00
abhigyanpatwariandClaude Opus 4.6 8e7d976c2a fix: gracefully skip files when language parser is unavailable (#136)
Instead of crashing the pipeline when a native tree-sitter binding
(e.g. tree-sitter-swift) fails to build, skip those files early and
warn the user with an actionable message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:23:05 +05:30
Güneş Bizim 46b4b7e157 Merge origin/main — add Kotlin/Swift support, resolve conflicts
- Resolved FUNCTION_NODE_TYPES: keep 'anonymous_function' for PHP (php_only grammar),
  add Kotlin 'lambda_literal' and Swift 'init_declaration'/'deinit_declaration'
- Resolved pipeline.ts: adopt chunked pipeline structure, integrate
  processRoutesFromExtracted into per-chunk worker data processing
- Resolved framework-detection.ts: use upstream AST-BASED FRAMEWORK DETECTION heading
- Fixed accumulated/mergeResult in parse-worker to include routes field
2026-03-01 22:33:23 +03:00
abhigyanpatwariandClaude Opus 4.6 cbeb0e231a docs: add Kotlin to supported languages in README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:23:04 +05:30
abhigyanpatwariandClaude Opus 4.6 3431edcea0 fix(test): update ingestion-utils test for Kotlin support
Move .kt from unsupported list to supported, add Kotlin test case
for .kt and .kts extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:18:21 +05:30
abhigyanpatwariandClaude Opus 4.6 40cb863cb4 chore: update package-lock.json with tree-sitter-kotlin
The Kotlin PR added tree-sitter-kotlin to package.json but didn't
include the lockfile update, causing npm ci to fail in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:14:36 +05:30
abhigyanpatwari ee95808478 Merge pull request #84 from magyargergo/feat/kotlin-language-support
feat: add Kotlin language support
2026-03-01 23:11:25 +05:30
abhigyanpatwari 3e3ea86ce4 Merge origin/main into feat/kotlin-language-support 2026-03-01 23:10:52 +05:30
abhigyanpatwariandClaude Opus 4.6 1a52d05131 fix(test): use dangerouslyIgnoreUnhandledErrors instead of forceExit
forceExit killed the fork worker before local-backend.test.ts finished,
losing 12 test results. The real issue is KuzuDB's C++ destructor
segfaulting during fork process exit — all tests pass but vitest
reports the post-test crash as a failure.

dangerouslyIgnoreUnhandledErrors ignores the process-level crash
without affecting test results (98/98 tests still run and report).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:36:38 +05:30
abhigyanpatwariandClaude Opus 4.6 3d64e26f8f fix(test): add forceExit to prevent KuzuDB native cleanup hang in CI
KuzuDB's C++ destructor crashes the vitest fork worker on exit,
causing a ~7 minute hang before timeout. forceExit kills the
worker immediately after tests complete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:33:18 +05:30
abhigyanpatwariandClaude Opus 4.6 3576802574 fix(test): use HEAD~1 instead of root commit in staleness test
GitHub Actions shallow clones don't have the root commit available,
causing checkStaleness to fail silently. HEAD~1 is always available.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:23:06 +05:30
abhigyanpatwariandClaude Opus 4.6 20ebd6b781 feat: security hardening, MCP improvements, skills, hooks, and CLI updates
- Export security primitives (CYPHER_WRITE_RE, isWriteQuery, isTestFilePath,
  VALID_NODE_LABELS, VALID_RELATION_TYPES) from local-backend
- Improve MCP kuzu-adapter with better query handling
- Add PR review skill for Claude, Cursor, and npm package
- Add CLI guide and CLI skills
- Update hooks for Claude plugin and Cursor integration
- Remove deprecated claude-hooks.ts CLI module
- Update eval-server, setup, and analyze CLI commands
- Improve CSV generator and ingestion processors
- Update CLAUDE.md and AGENTS.md configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:13:42 +05:30
abhigyanpatwariandClaude Opus 4.6 8a100a76d3 test: add test suite with vitest (unit + integration + fixtures)
- 59 test files covering unit and integration tests
- vitest config with coverage thresholds and fork pooling
- Test fixtures (mini-repo + multi-language sample code)
- Add vitest + coverage-v8 to devDependencies
- Add test scripts (test, test:integration, test:all, test:watch, test:coverage)
- Move typescript to devDependencies where it belongs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:07:02 +05:30
abhigyanpatwariandClaude Opus 4.6 c129e71ee7 ci: harden publish pipeline with CI gate, version check, and provenance
- Add workflow_call trigger to ci.yml so publish can reuse it as a gate
- Replace minimal publish.yml with hardened pipeline:
  - Full CI must pass before publish (typecheck + tests + cross-platform)
  - Verify git tag matches package.json version
  - Explicit build step + dry-run before real publish
  - npm provenance attestation enabled
  - Auto-create GitHub Release with generated notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:02:10 +05:30
Abhigyan Patwari 80eff73459 Add notice about GitNexus cryptocurrency claims
Added important notice regarding cryptocurrency affiliations.
2026-03-01 11:29:00 +05:30
Gary Magyar 48c8e6fe57 chore: merge upstream main (swift optional deps) and reorder kotlin entries
Merge origin/main which moves tree-sitter-swift to optionalDependencies
with conditional imports. Reorder Kotlin entries before C/C++/PHP in all
files so they don't sit adjacent to Swift entries, preventing future
merge conflicts when upstream modifies Swift support.
2026-02-28 12:55:55 +00:00
abhigyanpatwariandClaude Opus 4.6 2eca3e0da3 fix(swift): move tree-sitter-swift to optionalDependencies and use conditional imports
The PR merge reverted the Swift install fix. tree-sitter-swift must be
in optionalDependencies with conditional createRequire imports, otherwise
npm install fails on systems where the native build can't succeed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:14:53 +05:30
Gary Magyar e046bf734d chore: merge upstream main into feat/kotlin-language-support
Resolve conflicts between Kotlin and Swift language support additions.
Both languages are now fully supported side by side.
2026-02-28 12:30:53 +00:00
Bhaskar Lalwani b30248f969 Updated README with Discord and badge updates
Added Discord link and updated badges for npm and license.
2026-02-28 17:44:00 +05:30
abhigyanpatwariandClaude Opus 4.6 eb48c7352e fix: read CLI version from package.json instead of hardcoding
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:32:28 +05:30
abhigyanpatwariandClaude Opus 4.6 29db66c304 chore: bump version to 1.3.5
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:07:34 +05:30
Abhigyan Patwari b7c582de76 Merge pull request #94 from jandyx/feat/swift-language-support
feat(swift): full Swift / iOS language support with SPM import resolution
2026-02-28 16:03:27 +05:30
Gary Magyar 508402fd4a feat: add full Kotlin language support 2026-02-28 10:06:52 +00:00
Gary Magyar da63281a5a Merge remote-tracking branch 'origin/main' into feat/kotlin-language-support
# Conflicts:
#	gitnexus/src/core/ingestion/parsing-processor.ts
#	gitnexus/src/core/ingestion/workers/parse-worker.ts
2026-02-28 08:57:40 +00:00
abhigyanpatwariandClaude Opus 4.6 c758f4eaf0 chore: bump version to 1.3.4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 07:56:08 +05:30
Abhigyan Patwari fd507a19ae Merge pull request #105 from christopheralex-cc/fix/web-ui-server-connect-path-mismatch
fix(web): map API path field to repoPath in fetchRepoInfo
2026-02-28 07:53:01 +05:30
Abhigyan Patwari 799de20172 Merge pull request #102 from PurpleNewNew/feat/ast-decorator-detection
feat(ingestion): add AST decorator-based entrypoint hints
2026-02-28 07:41:57 +05:30
Abhigyan Patwari 2be88ae1f8 Merge pull request #61 from strazzere/fix/refactor_shell_commands
fix: ensure exec usage does not allow poisoning
2026-02-28 07:13:25 +05:30
Abhigyan Patwari 019ed3ff85 Merge pull request #99 from abhigyanpatwari/fix/lazy-embed-import
fix: lazy-import embeddings to avoid onnxruntime crash on Node v24+
2026-02-27 18:14:59 +05:30
Gary Magyar 43f525d056 fix(kotlin): guard against double-appending .* to wildcard import paths
Add endsWith('.*') check before appending wildcard suffix to prevent
possible double-append if grammar returns identifier text that already
includes the wildcard.
2026-02-27 10:28:39 +00:00
Gary Magyar e2a8bfa5ab fix(kotlin): enable import dependency tree resolution for Kotlin files
Add .kt/.kts to EXTENSIONS array, parameterize Java resolvers into JVM
resolvers (resolveJvmWildcard, resolveJvmMemberImport), and unify
Java+Kotlin dispatch in both import processing paths. Detect wildcard
imports via AST child node inspection in parse worker.

Validated against okhttp repo: 524 .kt files detected, imports resolve
correctly to .kt files (e.g. okhttp3.OkHttpClient -> OkHttpClient.kt).
2026-02-27 10:26:23 +00:00
Gary Magyar ee6753bf05 fix(kotlin): capture constructor-based heritage (class Foo : Bar())
The heritage query only matched bare user_type delegation specifiers
(interface implementation), missing constructor_invocation patterns
used for class extension. Adds a second heritage pattern for
constructor invocations, capturing ~3x more heritage edges.
2026-02-27 09:28:58 +00:00
Gary Magyar 1b8c3c77af feat(kotlin): distinguish interfaces from classes in knowledge graph
tree-sitter-kotlin (fwcd) has no interface_declaration node — both
interfaces and classes are class_declaration nodes. Use anonymous
keyword literal matching ("interface" vs "class") to produce the
correct @definition.interface / @definition.class captures.

Verified against two real Kotlin repos: a small one (3 Interface,
92 Class) and a large one (35 Interface, 677 Class, 5998 Function).
2026-02-27 09:09:26 +00:00
Güneş Bizim a7fc9d2f88 feat(laravel): add route detection and route group support
Parse Route::* calls from PHP AST using a procedural walk that tracks
group nesting state (middleware, prefix, controller cascade). Creates
CALLS edges from route files to controller methods.

Supported patterns:
- Route::get/post/put/patch/delete/any/match with [Controller::class, 'method']
- Route::resource / Route::apiResource (expanded to individual actions)
- Invokable controllers (Controller::class -> __invoke)
- String syntax ('Controller@method')
- Route::middleware()->group() fluent chains (arbitrary depth)
- Route::prefix()->name()->group() chains
- Route::controller(X::class)->group() shared controller
- Route::group(['middleware' => ...], fn) array API
- Nested groups with full middleware/prefix cascade

Implementation notes:
- Uses anonymous_function node type (php_only grammar, not anonymous_function_creation_expression)
- File paths from workers are relative; check startsWith('routes/') not includes('/routes/')
- Edges: File -> Method with reason='laravel-route', confidence 0.9 (import-resolved)
- Mirrored to gitnexus-web inline in processCalls (no worker layer)
2026-02-27 11:17:36 +03:00
christopher 0074fd71ff fix(web): map API path field to repoPath in fetchRepoInfo
The backend `/api/repo` endpoint returns `path` but `ServerRepoInfo`
expects `repoPath`, causing `undefined.split('/')` crash in App.tsx
when connecting to a local gitnexus serve instance.

Fixes #92
2026-02-27 16:05:47 +08:00
PurpleNewNew de935a4f4c feat(ingestion): add AST decorator-based entrypoint hints 2026-02-27 15:40:42 +08:00
Abhigyan Patwari 8c41970631 Merge pull request #96 from abhigyanpatwari/fix/mcp-no-repos-crash
fix(mcp): don't crash server when no repos are indexed
2026-02-27 11:35:31 +05:30
abhigyanpatwariandClaude Opus 4.6 5c3a32d0c6 fix(kuzu): remove duplicate ftsLoaded declaration that broke typecheck
The module-level `let ftsLoaded` was declared twice (line 19 and 679),
causing TS2451. Removed the duplicate and cleaned up redundant
assignments in loadFTSExtension.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 11:31:07 +05:30
abhigyanpatwariandClaude Opus 4.6 a8b3c6b23f fix(mcp): don't crash server when no repos are indexed (#91)
The MCP server called process.exit(1) at startup when no repositories
were found in the registry. This prevented users from configuring the
MCP integration before running `gitnexus analyze`.

The server now starts gracefully with 0 repos and discovers newly
indexed repos lazily via refreshRepos() on each tool call.

Closes #91

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:36:35 +05:30
jandyx 15caf1e014 fix(swift): add missing Enum→Enum CodeRelation pair to schema 2026-02-27 12:00:41 +08:00
jandyx 1ed34a0007 docs: update supported languages list to include PHP and Swift 2026-02-27 11:45:05 +08:00
jandyx 7a4bc9a260 docs: improve postinstall script comments with background and TODO 2026-02-27 11:39:14 +08:00
jandyx 3872a73875 feat(swift): add prebuilt Swift WASM binary for web package
Sourced from tree-sitter-wasms@0.1.13 prebuilt collection.
2026-02-27 11:22:17 +08:00
jandyx f557716998 fix(swift): improve postinstall to auto-rebuild after patching binding.gyp
The script now detects missing native binding and runs node-gyp rebuild
after patching. This handles the case where tree-sitter-swift's own
postinstall fails during npm install — our postinstall picks up,
patches binding.gyp, and rebuilds successfully.
2026-02-27 11:14:45 +08:00
jandyx ae8a76511d fix(swift): address review gaps — schema, call-processor, web package, postinstall
- Add init_declaration/deinit_declaration to call-processor FUNCTION_NODE_TYPES
  and findEnclosingFunction (syncs with parse-worker, avoids Dart PR #83 rejection)
- Add 7 missing CodeRelation FROM-TO pairs in schema.ts to eliminate analyze warnings
  (Function→Property, Constructor→Property/Typedef, Enum→Class/Interface,
   Struct→Interface, TypeAlias→Class)
- Mirror all Swift support to gitnexus-web: supported-languages, utils, queries,
  framework-detection, entry-point-scoring, parser-loader WASM path
- Add postinstall script to patch tree-sitter-swift binding.gyp actions array
2026-02-27 09:47:05 +08:00
jandyx e803e7e9d6 feat(swift): add comprehensive Swift/iOS language support
- Enable Swift in supported languages enum
- Add tree-sitter-swift parser loading (v0.6.0)
- Add .swift file extension mapping
- Implement full tree-sitter queries (class, struct, enum, protocol,
  extension, actor, function, property, init, imports, calls, heritage)
- Add Swift export detection (public/open modifiers)
- Add Swift/iOS built-in name filtering (~70 entries: stdlib, UIKit,
  Foundation, GCD, Combine, collection methods)
- Add SPM module import resolution (Sources/<Target>/ scanning)
- Add iOS/SwiftUI framework path detection with entry point multipliers
- Add Swift entry point scoring patterns (UIKit lifecycle, SwiftUI body,
  Coordinator, AppDelegate/SceneDelegate)
- Add Swift test file detection patterns
2026-02-27 08:40:47 +08:00
Gary Magyar c37b63ae8b feat: add Kotlin language support
Add end-to-end Kotlin parsing, symbol extraction, and visibility detection.
Extract findSiblingChild helper into utils.ts for clean AST traversal of
Kotlin's modifiers/visibility_modifier sibling pattern. Fix pre-existing
duplicate ftsLoaded declaration in kuzu-adapter.ts.

Files changed:
- supported-languages.ts: add Kotlin enum member
- parser-loader.ts, parse-worker.ts: register tree-sitter-kotlin
- tree-sitter-queries.ts: add Kotlin queries for classes, interfaces,
  objects, functions, properties, imports, calls, and heritage
- parsing-processor.ts, parse-worker.ts: add Kotlin visibility detection
- call-processor.ts, parse-worker.ts: add Kotlin builtins and node types
- utils.ts: add .kt/.kts extension mapping and findSiblingChild helper
- package.json: add tree-sitter-kotlin dependency
2026-02-26 17:01:35 +00:00
Tim Strazzere 73590b2862 fix: ensure exec usage does not allow poisoning
Previous usage was vulnerable to "poisoned" tags
which could enduce commands to be run when a
`detectChanges` command was hit. This was primarily
fixed in `local-backend.ts` however I changes the
`execSync` usages where any injection was potentially
able to be performed (e.g. staleness).

Skipped touching wiki and generator as those use static
input, though these should potentially be changed over
in the future.
2026-02-24 13:42:29 -08:00
131 changed files with 12410 additions and 661 deletions
@@ -0,0 +1,82 @@
---
name: gitnexus-cli
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
---
# GitNexus CLI Commands
All commands work via `npx` — no global install required.
## Commands
### analyze — Build or refresh the index
```bash
npx gitnexus analyze
```
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
| Flag | Effect |
| -------------- | ---------------------------------------------------------------- |
| `--force` | Force full re-index even if up to date |
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
### status — Check index freshness
```bash
npx gitnexus status
```
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
### clean — Delete the index
```bash
npx gitnexus clean
```
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
| Flag | Effect |
| --------- | ------------------------------------------------- |
| `--force` | Skip confirmation prompt |
| `--all` | Clean all indexed repos, not just the current one |
### wiki — Generate documentation from the graph
```bash
npx gitnexus wiki
```
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
| Flag | Effect |
| ------------------- | ----------------------------------------- |
| `--force` | Force full regeneration |
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--gist` | Publish wiki as a public GitHub Gist |
### list — Show all indexed repos
```bash
npx gitnexus list
```
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
## After Indexing
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
## Troubleshooting
- **"Not inside a git repository"**: Run from a directory inside a git repo
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
@@ -6,6 +6,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
# Debugging with GitNexus
## When to Use
- "Why is this function failing?"
- "Trace where this error comes from"
- "Who calls this method?"
@@ -37,17 +38,18 @@ description: "Use when the user is debugging a bug, tracing an error, or asking
## Debugging Patterns
| Symptom | GitNexus Approach |
|---------|-------------------|
| Error message | `gitnexus_query` for error text → `context` on throw sites |
| Wrong return value | `context` on the function → trace callees for data flow |
| Intermittent failure | `context` → look for external calls, async deps |
| Performance issue | `context` → find symbols with many callers (hot paths) |
| Recent regression | `detect_changes` to see what your changes affect |
| Symptom | GitNexus Approach |
| -------------------- | ---------------------------------------------------------- |
| Error message | `gitnexus_query` for error text → `context` on throw sites |
| Wrong return value | `context` on the function → trace callees for data flow |
| Intermittent failure | `context` → look for external calls, async deps |
| Performance issue | `context` → find symbols with many callers (hot paths) |
| Recent regression | `detect_changes` to see what your changes affect |
## Tools
**gitnexus_query** — find code related to error:
```
gitnexus_query({query: "payment validation error"})
→ Processes: CheckoutFlow, ErrorHandling
@@ -55,6 +57,7 @@ gitnexus_query({query: "payment validation error"})
```
**gitnexus_context** — full context for a suspect:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
@@ -63,6 +66,7 @@ gitnexus_context({name: "validatePayment"})
```
**gitnexus_cypher** — custom call chain traces:
```cypher
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
RETURN [n IN nodes(path) | n.name] AS chain
@@ -6,6 +6,7 @@ description: "Use when the user asks how code works, wants to understand archite
# Exploring Codebases with GitNexus
## When to Use
- "How does authentication work?"
- "What's the project structure?"
- "Show me the main components"
@@ -37,16 +38,17 @@ description: "Use when the user asks how code works, wants to understand archite
## Resources
| Resource | What you get |
|----------|-------------|
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
| Resource | What you get |
| --------------------------------------- | ------------------------------------------------------- |
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
## Tools
**gitnexus_query** — find execution flows related to a concept:
```
gitnexus_query({query: "payment processing"})
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
@@ -54,6 +56,7 @@ gitnexus_query({query: "payment processing"})
```
**gitnexus_context** — 360-degree view of a symbol:
```
gitnexus_context({name: "validateUser"})
→ Incoming calls: loginHandler, apiMiddleware
@@ -0,0 +1,64 @@
---
name: gitnexus-guide
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
---
# GitNexus Guide
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
## Always Start Here
For any task involving code understanding, debugging, impact analysis, or refactoring:
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
## Skills
| Task | Skill to read |
| -------------------------------------------- | ------------------- |
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
| Rename / extract / split / refactor | `gitnexus-refactoring` |
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
## Tools Reference
| Tool | What it gives you |
| ---------------- | ------------------------------------------------------------------------ |
| `query` | Process-grouped code intelligence — execution flows related to a concept |
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
## Resources Reference
Lightweight reads (~100-500 tokens) for navigation:
| Resource | Content |
| ---------------------------------------------- | ----------------------------------------- |
| `gitnexus://repo/{name}/context` | Stats, staleness check |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
| `gitnexus://repo/{name}/processes` | All execution flows |
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
## Graph Schema
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```
@@ -6,6 +6,7 @@ description: "Use when the user wants to know what will break if they change som
# Impact Analysis with GitNexus
## When to Use
- "Is it safe to change this function?"
- "What will break if I modify X?"
- "Show me the blast radius"
@@ -37,24 +38,25 @@ description: "Use when the user wants to know what will break if they change som
## Understanding Output
| Depth | Risk Level | Meaning |
|-------|-----------|---------|
| d=1 | **WILL BREAK** | Direct callers/importers |
| d=2 | LIKELY AFFECTED | Indirect dependencies |
| d=3 | MAY NEED TESTING | Transitive effects |
| Depth | Risk Level | Meaning |
| ----- | ---------------- | ------------------------ |
| d=1 | **WILL BREAK** | Direct callers/importers |
| d=2 | LIKELY AFFECTED | Indirect dependencies |
| d=3 | MAY NEED TESTING | Transitive effects |
## Risk Assessment
| Affected | Risk |
|----------|------|
| <5 symbols, few processes | LOW |
| 5-15 symbols, 2-5 processes | MEDIUM |
| >15 symbols or many processes | HIGH |
| Affected | Risk |
| ------------------------------ | -------- |
| <5 symbols, few processes | LOW |
| 5-15 symbols, 2-5 processes | MEDIUM |
| >15 symbols or many processes | HIGH |
| Critical path (auth, payments) | CRITICAL |
## Tools
**gitnexus_impact** — the primary tool for symbol blast radius:
```
gitnexus_impact({
target: "validateUser",
@@ -72,6 +74,7 @@ gitnexus_impact({
```
**gitnexus_detect_changes** — git-diff based impact analysis:
```
gitnexus_detect_changes({scope: "staged"})
@@ -0,0 +1,163 @@
---
name: gitnexus-pr-review
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
## Workflow
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
## Checklist
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
```
## Review Dimensions
| Dimension | How GitNexus Helps |
| --- | --- |
| **Correctness** | `context` shows callers — are they all compatible with the change? |
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
## Risk Assessment
| Signal | Risk |
| --- | --- |
| Changes touch <3 symbols, 0-1 processes | LOW |
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
| Changes touch >10 symbols or many processes | HIGH |
| Changes touch auth, payments, or data integrity code | CRITICAL |
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
```
## Example: "Review PR #42"
```
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
- MEDIUM risk — 3 changed symbols affect 2 execution flows
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
- BUG: createPayment depends on PaymentInput type which changed
- OK: formatAmount change is backwards-compatible
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
```
## Review Output Format
Structure your review as:
```markdown
## PR Review: <title>
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
### Changes Summary
- <N> symbols changed across <M> files
- <P> execution flows affected
### Findings
1. **[severity]** Description of finding
- Evidence from GitNexus tools
- Affected callers/flows
### Missing Coverage
- Callers not updated in PR: ...
- Untested flows: ...
### Recommendation
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
```
@@ -6,6 +6,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
# Refactoring with GitNexus
## When to Use
- "Rename this function safely"
- "Extract this into a module"
- "Split this service"
@@ -26,6 +27,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
## Checklists
### Rename Symbol
```
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
@@ -35,6 +37,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
### Extract Module
```
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
@@ -45,6 +48,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
```
### Split Function/Service
```
- [ ] gitnexus_context({name: target}) — understand all callees
- [ ] Group callees by responsibility
@@ -58,6 +62,7 @@ description: "Use when the user wants to rename, extract, split, move, or restru
## Tools
**gitnexus_rename** — automated multi-file rename:
```
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
→ 12 edits across 8 files
@@ -66,6 +71,7 @@ gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_
```
**gitnexus_impact** — map all dependents first:
```
gitnexus_impact({target: "validateUser", direction: "upstream"})
→ d=1: loginHandler, apiMiddleware, testUtils
@@ -73,6 +79,7 @@ gitnexus_impact({target: "validateUser", direction: "upstream"})
```
**gitnexus_detect_changes** — verify your changes after refactoring:
```
gitnexus_detect_changes({scope: "all"})
→ Changed: 8 files, 12 symbols
@@ -81,6 +88,7 @@ gitnexus_detect_changes({scope: "all"})
```
**gitnexus_cypher** — custom reference queries:
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
RETURN caller.name, caller.filePath ORDER BY caller.filePath
@@ -88,12 +96,12 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath
## Risk Rules
| Risk Factor | Mitigation |
|-------------|------------|
| Many callers (>5) | Use gitnexus_rename for automated updates |
| Cross-area refs | Use detect_changes after to verify scope |
| String/dynamic refs | gitnexus_query to find them |
| External/public API | Version and deprecate properly |
| Risk Factor | Mitigation |
| ------------------- | ----------------------------------------- |
| Many callers (>5) | Use gitnexus_rename for automated updates |
| Cross-area refs | Use detect_changes after to verify scope |
| String/dynamic refs | gitnexus_query to find them |
| External/public API | Version and deprecate properly |
## Example: Rename `validateUser` to `authenticateUser`
+49
View File
@@ -1,8 +1,11 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
jobs:
typecheck:
@@ -18,3 +21,49 @@ jobs:
working-directory: gitnexus
- run: npx tsc --noEmit
working-directory: gitnexus
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx vitest run test/unit --coverage --coverage.thresholdAutoUpdate=false
working-directory: gitnexus
integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx vitest run test/integration
working-directory: gitnexus
cross-platform:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx vitest run test/unit
working-directory: gitnexus
+32 -3
View File
@@ -6,10 +6,15 @@ on:
- 'v*'
jobs:
ci:
uses: ./.github/workflows/ci.yml
publish:
needs: ci
runs-on: ubuntu-latest
permissions:
contents: read
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -20,9 +25,33 @@ jobs:
cache-dependency-path: gitnexus/package-lock.json
- run: npm ci
working-directory: gitnexus
- run: npx tsc --noEmit
- name: Verify version consistency
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
PKG_VERSION=$(node -p "require('./package.json').version")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag version (v$TAG_VERSION) does not match package.json version ($PKG_VERSION)"
exit 1
fi
echo "Version verified: $PKG_VERSION"
working-directory: gitnexus
- run: npm publish
- name: Build
run: npm run build
working-directory: gitnexus
- name: Dry-run publish
run: npm publish --dry-run
working-directory: gitnexus
- name: Publish to npm
run: npm publish --provenance --access public
working-directory: gitnexus
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
+59 -43
View File
@@ -1,62 +1,78 @@
<!-- gitnexus:start -->
# GitNexus MCP
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitnexusV2** (1348 symbols, 3469 relationships, 104 execution flows).
This project is indexed by GitNexus as **GitNexus** (1573 symbols, 4146 relationships, 120 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Start Here
## Always Do
For any task involving code understanding, debugging, impact analysis, or refactoring, you must:
- **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"})`.
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
## When Debugging
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
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
## Skills
## When Refactoring
| 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` |
- **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.
## Tools Reference
## Never Do
| Tool | What it gives you |
|------|-------------------|
| `query` | Process-grouped code intelligence — execution flows related to a concept |
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
- 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.
## Resources Reference
## Tools Quick Reference
Lightweight reads (~100-500 tokens) for navigation:
| 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 ..."})` |
| Resource | Content |
## 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/{name}/context` | Stats, staleness check |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
| `gitnexus://repo/{name}/processes` | All execution flows |
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
| `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 |
## Graph Schema
## Self-Check Before Finishing
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
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
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```
## CLI
<!-- gitnexus:end -->
- Re-index: `npx gitnexus analyze`
- Check freshness: `npx gitnexus status`
- Generate docs: `npx gitnexus wiki`
<!-- gitnexus:end -->
+42
View File
@@ -0,0 +1,42 @@
# Changelog
All notable changes to GitNexus will be documented in this file.
## [1.3.10] - 2026-03-07
### Security
- **MCP transport buffer cap**: Added 10 MB `MAX_BUFFER_SIZE` limit to prevent out-of-memory attacks via oversized `Content-Length` headers or unbounded newline-delimited input
- **Content-Length validation**: Reject `Content-Length` values exceeding the buffer cap before allocating memory
- **Stack overflow prevention**: Replaced recursive `readNewlineMessage` with iterative loop to prevent stack overflow from consecutive empty lines
- **Ambiguous prefix hardening**: Tightened `looksLikeContentLength` to require 14+ bytes before matching, preventing false framing detection on short input
- **Closed transport guard**: `send()` now rejects with a clear error when called after `close()`, with proper write-error propagation
### Added
- **Dual-framing MCP transport** (`CompatibleStdioServerTransport`): Auto-detects Content-Length (Codex/OpenCode) and newline-delimited JSON (Cursor/Claude Code) framing on the first message, responds in the same format (#207)
- **Lazy CLI module loading**: All CLI subcommands now use `createLazyAction()` to defer heavy imports (tree-sitter, ONNX, KuzuDB) until invocation, significantly improving `gitnexus mcp` startup time (#207)
- **Type-safe lazy actions**: `createLazyAction` uses constrained generics to validate export names against module types at compile time
- **Regression test suite**: 13 unit tests covering transport framing, security hardening, buffer limits, and lazy action loading
### Fixed
- **CALLS edge sourceId alignment**: `findEnclosingFunctionId` now generates IDs with `:startLine` suffix matching node creation format, fixing process detector finding 0 entry points (#194)
- **LRU cache zero maxSize crash**: Guard `createASTCache` against `maxSize=0` when repos have no parseable files (#144)
### Changed
- Transport constructor accepts `NodeJS.ReadableStream` / `NodeJS.WritableStream` (widened from concrete `ReadStream`/`WriteStream`)
- `processReadBuffer` simplified to break on first error instead of stale-buffer retry loop
## [1.3.9] - 2026-03-06
### Fixed
- Aligned CALLS edge sourceId with node ID format in parse worker (#194)
## [1.3.8] - 2026-03-05
### Fixed
- Force-exit after analyze to prevent KuzuDB native cleanup hang (#192)
+59 -43
View File
@@ -1,62 +1,78 @@
<!-- gitnexus:start -->
# GitNexus MCP
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitnexusV2** (1348 symbols, 3469 relationships, 104 execution flows).
This project is indexed by GitNexus as **GitNexus** (1573 symbols, 4146 relationships, 120 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
GitNexus provides a knowledge graph over this codebase — call chains, blast radius, execution flows, and semantic search.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Start Here
## Always Do
For any task involving code understanding, debugging, impact analysis, or refactoring, you must:
- **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"})`.
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
## When Debugging
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
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
## Skills
## When Refactoring
| 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` |
- **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.
## Tools Reference
## Never Do
| Tool | What it gives you |
|------|-------------------|
| `query` | Process-grouped code intelligence — execution flows related to a concept |
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos |
- 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.
## Resources Reference
## Tools Quick Reference
Lightweight reads (~100-500 tokens) for navigation:
| 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 ..."})` |
| Resource | Content |
## 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/{name}/context` | Stats, staleness check |
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
| `gitnexus://repo/{name}/processes` | All execution flows |
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
| `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 |
## Graph Schema
## Self-Check Before Finishing
**Nodes:** File, Function, Class, Interface, Method, Community, Process
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
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
```cypher
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
RETURN caller.name, caller.filePath
```
## CLI
<!-- gitnexus:end -->
- Re-index: `npx gitnexus analyze`
- Check freshness: `npx gitnexus status`
- Generate docs: `npx gitnexus wiki`
<!-- gitnexus:end -->
+23 -6
View File
@@ -1,13 +1,30 @@
# GitNexus
⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
<a href="https://trendshift.io/repositories/19809" target="_blank"><img src="https://trendshift.io/api/badge/repositories/19809" alt="abhigyanpatwari%2FGitNexus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<div align="center">
**Building git for agent context.**
<a href="https://trendshift.io/repositories/19809" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/19809" alt="abhigyanpatwari%2FGitNexus | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
<h2>Join the official Discord to discuss ideas, issues etc!</h2>
<a href="https://discord.gg/AAsRVT6fGb">
<img src="https://img.shields.io/discord/1477255801545429032?color=5865F2&logo=discord&logoColor=white" alt="Discord"/>
</a>
<a href="https://www.npmjs.com/package/gitnexus">
<img src="https://img.shields.io/npm/v/gitnexus.svg" alt="npm version"/>
</a>
<a href="https://polyformproject.org/licenses/noncommercial/1.0.0/">
<img src="https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg" alt="License: PolyForm Noncommercial"/>
</a>
</div>
**Building nervous system for agent context.**
Indexes any codebase into a knowledge graph — every dependency, call chain, cluster, and execution flow — then exposes it through smart tools so AI agents never miss code.
[![npm version](https://img.shields.io/npm/v/gitnexus.svg)](https://www.npmjs.com/package/gitnexus)
[![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm%20Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0/)
@@ -303,7 +320,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
### Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust
TypeScript, JavaScript, Python, Java, Kotlin, C, C++, C#, Go, Rust, PHP, Swift
---
@@ -465,7 +482,7 @@ The wiki generator reads the indexed graph structure, groups files into modules
- [X] Wiki Generation, Multi-File Rename, Git-Diff Impact Analysis
- [X] Process-Grouped Search, 360-Degree Context, Claude Code Hooks
- [X] Multi-Repo MCP, Zero-Config Setup, 9 Language Support
- [X] Multi-Repo MCP, Zero-Config Setup, 11 Language Support
- [X] Community Detection, Process Detection, Confidence Scoring
- [X] Hybrid Search, Vector Index
@@ -1,11 +1,11 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.3.3",
"version": "1.3.6",
"author": {
"name": "GitNexus"
},
"homepage": "https://github.com/nicosxt/gitnexus",
"repository": "https://github.com/nicosxt/gitnexus",
"homepage": "https://github.com/abhigyanpatwari/GitNexus",
"repository": "https://github.com/abhigyanpatwari/GitNexus",
"keywords": ["code-intelligence", "knowledge-graph", "mcp", "static-analysis"]
}
@@ -105,12 +105,14 @@ function main() {
// stdout fd at OS level, making it unusable in subprocess contexts).
let result = '';
const isWin = process.platform === 'win32';
// Try direct gitnexus binary first (faster if globally installed)
try {
const child = spawnSync(
'gitnexus',
['augment', pattern],
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: isWin }
);
if (child.status === 0 && child.stderr && child.stderr.trim()) {
result = child.stderr;
@@ -123,7 +125,7 @@ function main() {
const child = spawnSync(
'npx',
['-y', 'gitnexus', 'augment', pattern],
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: isWin }
);
if (child.status === 0 && child.stderr && child.stderr.trim()) {
result = child.stderr;
@@ -0,0 +1,163 @@
---
name: gitnexus-pr-review
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
## Workflow
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
## Checklist
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
```
## Review Dimensions
| Dimension | How GitNexus Helps |
| --- | --- |
| **Correctness** | `context` shows callers — are they all compatible with the change? |
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
## Risk Assessment
| Signal | Risk |
| --- | --- |
| Changes touch <3 symbols, 0-1 processes | LOW |
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
| Changes touch >10 symbols or many processes | HIGH |
| Changes touch auth, payments, or data integrity code | CRITICAL |
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
```
## Example: "Review PR #42"
```
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
- MEDIUM risk — 3 changed symbols affect 2 execution flows
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
- BUG: createPayment depends on PaymentInput type which changed
- OK: formatAmount change is backwards-compatible
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
```
## Review Output Format
Structure your review as:
```markdown
## PR Review: <title>
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
### Changes Summary
- <N> symbols changed across <M> files
- <P> execution flows affected
### Findings
1. **[severity]** Description of finding
- Evidence from GitNexus tools
- Affected callers/flows
### Missing Coverage
- Callers not updated in PR: ...
- Untested flows: ...
### Recommendation
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
```
@@ -0,0 +1,163 @@
---
name: gitnexus-pr-review
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
## Workflow
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
## Checklist
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
```
## Review Dimensions
| Dimension | How GitNexus Helps |
| --- | --- |
| **Correctness** | `context` shows callers — are they all compatible with the change? |
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
## Risk Assessment
| Signal | Risk |
| --- | --- |
| Changes touch <3 symbols, 0-1 processes | LOW |
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
| Changes touch >10 symbols or many processes | HIGH |
| Changes touch auth, payments, or data integrity code | CRITICAL |
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
```
## Example: "Review PR #42"
```
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
- MEDIUM risk — 3 changed symbols affect 2 execution flows
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
- BUG: createPayment depends on PaymentInput type which changed
- OK: formatAmount change is backwards-compatible
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
```
## Review Output Format
Structure your review as:
```markdown
## PR Review: <title>
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
### Changes Summary
- <N> symbols changed across <M> files
- <P> execution flows affected
### Findings
1. **[severity]** Description of finding
- Evidence from GitNexus tools
- Affected callers/flows
### Missing Coverage
- Callers not updated in PR: ...
- Untested flows: ...
### Recommendation
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
```
Binary file not shown.
@@ -10,5 +10,5 @@ export enum SupportedLanguages {
Rust = 'rust',
PHP = 'php',
// Ruby = 'ruby',
// Swift = 'swift',
Swift = 'swift',
}
@@ -216,6 +216,58 @@ export const processCalls = async (
});
});
// Extract Laravel routes from route files via procedural AST walk
if (language === 'php' && (file.path.includes('/routes/') || file.path.startsWith('routes/')) && file.path.endsWith('.php')) {
const extractedRoutes = extractLaravelRoutes(tree, file.path);
for (const route of extractedRoutes) {
if (!route.controllerName || !route.methodName) continue;
const controllerDefs = symbolTable.lookupFuzzy(route.controllerName);
if (controllerDefs.length === 0) continue;
const routeImportedFiles = importMap.get(route.filePath);
let controllerDef = controllerDefs[0];
let conf = controllerDefs.length === 1 ? 0.7 : 0.5;
if (routeImportedFiles) {
for (const def of controllerDefs) {
if (routeImportedFiles.has(def.filePath)) {
controllerDef = def;
conf = 0.9;
break;
}
}
}
const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName);
const routeSourceId = generateId('File', route.filePath);
if (!methodId) {
const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
const routeRelId = generateId('CALLS', `${routeSourceId}:route->${guessedId}`);
graph.addRelationship({
id: routeRelId,
sourceId: routeSourceId,
targetId: guessedId,
type: 'CALLS',
confidence: conf * 0.8,
reason: 'laravel-route',
});
continue;
}
const routeRelId = generateId('CALLS', `${routeSourceId}:route->${methodId}`);
graph.addRelationship({
id: routeRelId,
sourceId: routeSourceId,
targetId: methodId,
type: 'CALLS',
confidence: conf,
reason: 'laravel-route',
});
}
}
// Cleanup if re-parsed
if (wasReparsed) {
tree.delete();
@@ -223,6 +275,387 @@ export const processCalls = async (
}
};
// ============================================================================
// Laravel Route Extraction (procedural AST walk)
// ============================================================================
interface ExtractedRoute {
filePath: string;
httpMethod: string;
routePath: string | null;
controllerName: string | null;
methodName: string | null;
middleware: string[];
prefix: string | null;
lineNumber: number;
}
interface RouteGroupContext {
middleware: string[];
prefix: string | null;
controller: string | null;
}
const ROUTE_HTTP_METHODS = new Set([
'get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match',
]);
const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']);
const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'];
const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
function isRouteStaticCall(node: any): boolean {
if (node.type !== 'scoped_call_expression') return false;
const obj = node.childForFieldName?.('object') ?? node.children?.[0];
return obj?.text === 'Route';
}
function getCallMethodName(node: any): string | null {
const nameNode = node.childForFieldName?.('name') ??
node.children?.find((c: any) => c.type === 'name');
return nameNode?.text ?? null;
}
function getArguments(node: any): any {
return node.children?.find((c: any) => c.type === 'arguments') ?? null;
}
function findClosureBody(argsNode: any): any | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
if (child.type === 'argument') {
for (const inner of child.children ?? []) {
if (inner.type === 'anonymous_function' ||
inner.type === 'arrow_function') {
return inner.childForFieldName?.('body') ??
inner.children?.find((c: any) => c.type === 'compound_statement');
}
}
}
if (child.type === 'anonymous_function' ||
child.type === 'arrow_function') {
return child.childForFieldName?.('body') ??
child.children?.find((c: any) => c.type === 'compound_statement');
}
}
return null;
}
function findDescendant(node: any, type: string): any {
if (node.type === type) return node;
for (const child of (node.children ?? [])) {
const found = findDescendant(child, type);
if (found) return found;
}
return null;
}
function extractStringContent(node: any): string | null {
if (!node) return null;
const content = node.children?.find((c: any) => c.type === 'string_content');
if (content) return content.text;
if (node.type === 'string_content') return node.text;
return null;
}
function extractFirstStringArg(argsNode: any): string | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (!target) continue;
if (target.type === 'string' || target.type === 'encapsed_string') {
return extractStringContent(target);
}
}
return null;
}
function extractMiddlewareArg(argsNode: any): string[] {
if (!argsNode) return [];
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (!target) continue;
if (target.type === 'string' || target.type === 'encapsed_string') {
const val = extractStringContent(target);
return val ? [val] : [];
}
if (target.type === 'array_creation_expression') {
const items: string[] = [];
for (const el of target.children ?? []) {
if (el.type === 'array_element_initializer') {
const str = el.children?.find((c: any) => c.type === 'string' || c.type === 'encapsed_string');
const val = str ? extractStringContent(str) : null;
if (val) items.push(val);
}
}
return items;
}
}
return [];
}
function extractClassArg(argsNode: any): string | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (target?.type === 'class_constant_access_expression') {
return target.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
return null;
}
function extractControllerTarget(argsNode: any): { controller: string | null; method: string | null } {
if (!argsNode) return { controller: null, method: null };
const args: any[] = [];
for (const child of argsNode.children ?? []) {
if (child.type === 'argument') args.push(child.children?.[0]);
else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child);
}
const handlerNode = args[1];
if (!handlerNode) return { controller: null, method: null };
if (handlerNode.type === 'array_creation_expression') {
let controller: string | null = null;
let method: string | null = null;
const elements: any[] = [];
for (const el of handlerNode.children ?? []) {
if (el.type === 'array_element_initializer') elements.push(el);
}
if (elements[0]) {
const classAccess = findDescendant(elements[0], 'class_constant_access_expression');
if (classAccess) {
controller = classAccess.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
if (elements[1]) {
const str = findDescendant(elements[1], 'string');
method = str ? extractStringContent(str) : null;
}
return { controller, method };
}
if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') {
const text = extractStringContent(handlerNode);
if (text?.includes('@')) {
const [controller, method] = text.split('@');
return { controller, method };
}
}
if (handlerNode.type === 'class_constant_access_expression') {
const controller = handlerNode.children?.find((c: any) => c.type === 'name')?.text ?? null;
return { controller, method: '__invoke' };
}
return { controller: null, method: null };
}
interface ChainedRouteCall {
isRouteFacade: boolean;
terminalMethod: string;
attributes: { method: string; argsNode: any }[];
terminalArgs: any;
node: any;
}
function unwrapRouteChain(node: any): ChainedRouteCall | null {
if (node.type !== 'member_call_expression') return null;
const terminalMethod = getCallMethodName(node);
if (!terminalMethod) return null;
const terminalArgs = getArguments(node);
const attributes: { method: string; argsNode: any }[] = [];
let current = node.children?.[0];
while (current) {
if (current.type === 'member_call_expression') {
const method = getCallMethodName(current);
const args = getArguments(current);
if (method) attributes.unshift({ method, argsNode: args });
current = current.children?.[0];
} else if (current.type === 'scoped_call_expression') {
const obj = current.childForFieldName?.('object') ?? current.children?.[0];
if (obj?.text !== 'Route') return null;
const method = getCallMethodName(current);
const args = getArguments(current);
if (method) attributes.unshift({ method, argsNode: args });
return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node };
} else {
break;
}
}
return null;
}
function parseArrayGroupArgs(argsNode: any): RouteGroupContext {
const ctx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
if (!argsNode) return ctx;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (target?.type === 'array_creation_expression') {
for (const el of target.children ?? []) {
if (el.type !== 'array_element_initializer') continue;
const children = el.children ?? [];
const arrowIdx = children.findIndex((c: any) => c.type === '=>');
if (arrowIdx === -1) continue;
const key = extractStringContent(children[arrowIdx - 1]);
const val = children[arrowIdx + 1];
if (key === 'middleware') {
if (val?.type === 'string') {
const s = extractStringContent(val);
if (s) ctx.middleware.push(s);
} else if (val?.type === 'array_creation_expression') {
for (const item of val.children ?? []) {
if (item.type === 'array_element_initializer') {
const str = item.children?.find((c: any) => c.type === 'string');
const s = str ? extractStringContent(str) : null;
if (s) ctx.middleware.push(s);
}
}
}
} else if (key === 'prefix') {
ctx.prefix = extractStringContent(val) ?? null;
} else if (key === 'controller') {
if (val?.type === 'class_constant_access_expression') {
ctx.controller = val.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
}
}
}
return ctx;
}
function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] {
const routes: ExtractedRoute[] = [];
function resolveStack(stack: RouteGroupContext[]): { middleware: string[]; prefix: string | null; controller: string | null } {
const middleware: string[] = [];
let prefix: string | null = null;
let controller: string | null = null;
for (const ctx of stack) {
middleware.push(...ctx.middleware);
if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix;
if (ctx.controller) controller = ctx.controller;
}
return { middleware, prefix, controller };
}
function emitRoute(
httpMethod: string,
argsNode: any,
lineNumber: number,
groupStack: RouteGroupContext[],
chainAttrs: { method: string; argsNode: any }[],
) {
const effective = resolveStack(groupStack);
for (const attr of chainAttrs) {
if (attr.method === 'middleware') effective.middleware.push(...extractMiddlewareArg(attr.argsNode));
if (attr.method === 'prefix') {
const p = extractFirstStringArg(attr.argsNode);
if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p;
}
if (attr.method === 'controller') {
const cls = extractClassArg(attr.argsNode);
if (cls) effective.controller = cls;
}
}
const routePath = extractFirstStringArg(argsNode);
if (ROUTE_RESOURCE_METHODS.has(httpMethod)) {
const target = extractControllerTarget(argsNode);
const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS;
for (const action of actions) {
routes.push({
filePath, httpMethod, routePath,
controllerName: target.controller ?? effective.controller,
methodName: action,
middleware: [...effective.middleware],
prefix: effective.prefix,
lineNumber,
});
}
} else {
const target = extractControllerTarget(argsNode);
routes.push({
filePath, httpMethod, routePath,
controllerName: target.controller ?? effective.controller,
methodName: target.method,
middleware: [...effective.middleware],
prefix: effective.prefix,
lineNumber,
});
}
}
function walk(node: any, groupStack: RouteGroupContext[]) {
if (isRouteStaticCall(node)) {
const method = getCallMethodName(node);
if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) {
emitRoute(method, getArguments(node), node.startPosition.row, groupStack, []);
return;
}
if (method === 'group') {
const argsNode = getArguments(node);
const groupCtx = parseArrayGroupArgs(argsNode);
const body = findClosureBody(argsNode);
if (body) {
groupStack.push(groupCtx);
walkChildren(body, groupStack);
groupStack.pop();
}
return;
}
}
const chain = unwrapRouteChain(node);
if (chain) {
if (chain.terminalMethod === 'group') {
const groupCtx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
for (const attr of chain.attributes) {
if (attr.method === 'middleware') groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode));
if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode);
if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode);
}
const body = findClosureBody(chain.terminalArgs);
if (body) {
groupStack.push(groupCtx);
walkChildren(body, groupStack);
groupStack.pop();
}
return;
}
if (ROUTE_HTTP_METHODS.has(chain.terminalMethod) || ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)) {
emitRoute(chain.terminalMethod, chain.terminalArgs, node.startPosition.row, groupStack, chain.attributes);
return;
}
}
walkChildren(node, groupStack);
}
function walkChildren(node: any, groupStack: RouteGroupContext[]) {
for (const child of node.children ?? []) {
walk(child, groupStack);
}
}
walk(tree.rootNode, []);
return routes;
}
/**
* Resolution result with confidence scoring
*/
@@ -103,6 +103,26 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^Start$/, // Start methods
],
// Swift / iOS
'swift': [
/^viewDidLoad$/, // UIKit lifecycle
/^viewWillAppear$/, // UIKit lifecycle
/^viewDidAppear$/, // UIKit lifecycle
/^viewWillDisappear$/, // UIKit lifecycle
/^viewDidDisappear$/, // UIKit lifecycle
/^application\(/, // AppDelegate methods
/^scene\(/, // SceneDelegate methods
/^body$/, // SwiftUI View.body
/Coordinator$/, // Coordinator pattern
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
/^sceneWillResignActive$/, // SceneDelegate lifecycle
/^didFinishLaunchingWithOptions$/, // AppDelegate
/ViewController$/, // ViewController classes
/^configure[A-Z]/, // Configuration methods
/^setup[A-Z]/, // Setup methods
/^makeBody$/, // SwiftUI ViewModifier
],
// PHP / Laravel
'php': [
/Controller$/, // UserController (class name convention)
@@ -271,6 +291,10 @@ export function isTestFile(filePath: string): boolean {
p.includes('/src/test/') ||
// Rust test patterns (inline tests are different, but test files)
p.includes('/tests/') ||
// Swift/iOS test patterns
p.endsWith('tests.swift') ||
p.endsWith('test.swift') ||
p.includes('uitests/') ||
// C# test patterns
p.includes('.tests/') ||
p.includes('tests.cs') ||
@@ -257,22 +257,70 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
}
// ========== SWIFT / iOS ==========
// iOS App entry points (highest priority)
if (p.endsWith('/appdelegate.swift') || p.endsWith('/scenedelegate.swift') || p.endsWith('/app.swift')) {
return { framework: 'ios', entryPointMultiplier: 3.0, reason: 'ios-app-entry' };
}
// SwiftUI App entry (@main)
if (p.endsWith('app.swift') && p.includes('/sources/')) {
return { framework: 'swiftui', entryPointMultiplier: 3.0, reason: 'swiftui-app' };
}
// UIKit ViewControllers (high priority - screen entry points)
if ((p.includes('/viewcontrollers/') || p.includes('/controllers/') || p.includes('/screens/')) && p.endsWith('.swift')) {
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller' };
}
// ViewController by filename convention
if (p.endsWith('viewcontroller.swift') || p.endsWith('vc.swift')) {
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller-file' };
}
// Coordinator pattern (navigation entry points)
if (p.includes('/coordinators/') && p.endsWith('.swift')) {
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator' };
}
// Coordinator by filename
if (p.endsWith('coordinator.swift')) {
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator-file' };
}
// SwiftUI Views (moderate - reusable components)
if ((p.includes('/views/') || p.includes('/scenes/')) && p.endsWith('.swift')) {
return { framework: 'swiftui', entryPointMultiplier: 1.8, reason: 'swiftui-view' };
}
// Service layer
if (p.includes('/services/') && p.endsWith('.swift')) {
return { framework: 'ios-service', entryPointMultiplier: 1.8, reason: 'ios-service' };
}
// Router / navigation
if (p.includes('/router/') && p.endsWith('.swift')) {
return { framework: 'ios-router', entryPointMultiplier: 2.0, reason: 'ios-router' };
}
// ========== GENERIC PATTERNS ==========
// Any language: index files in API folders
if (p.includes('/api/') && (
p.endsWith('/index.ts') || p.endsWith('/index.js') ||
p.endsWith('/index.ts') || p.endsWith('/index.js') ||
p.endsWith('/__init__.py')
)) {
return { framework: 'api', entryPointMultiplier: 1.8, reason: 'api-index' };
}
// No framework detected - return null for graceful fallback (1.0 multiplier)
return null;
}
// ============================================================================
// FUTURE: AST-BASED PATTERNS (for Phase 3)
// PARTIALLY IMPLEMENTED: Route::* detection via procedural AST walk in parse-worker/call-processor
// Remaining: NestJS, Express, FastAPI, Flask, Spring, etc.
// ============================================================================
/**
@@ -306,4 +354,9 @@ export const FRAMEWORK_AST_PATTERNS = {
'actix': ['#[get', '#[post', '#[put', '#[delete'],
'axum': ['Router::new'],
'rocket': ['#[get', '#[post'],
// Swift/iOS
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'],
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'],
'combine': ['sink', 'assign', 'Publisher', 'Subscriber'],
};
@@ -396,6 +396,59 @@ export const PHP_QUERIES = `
[(name) (qualified_name)] @heritage.trait))) @heritage
`;
// Swift queries - works with tree-sitter-swift
export const SWIFT_QUERIES = `
; Classes
(class_declaration "class" name: (type_identifier) @name) @definition.class
; Structs
(class_declaration "struct" name: (type_identifier) @name) @definition.struct
; Enums
(class_declaration "enum" name: (type_identifier) @name) @definition.enum
; Extensions (mapped to class — no dedicated label in schema)
(class_declaration "extension" name: (user_type (type_identifier) @name)) @definition.class
; Actors
(class_declaration "actor" name: (type_identifier) @name) @definition.class
; Protocols (mapped to interface)
(protocol_declaration name: (type_identifier) @name) @definition.interface
; Type aliases
(typealias_declaration name: (type_identifier) @name) @definition.type
; Functions (top-level and methods)
(function_declaration name: (simple_identifier) @name) @definition.function
; Protocol method declarations
(protocol_function_declaration name: (simple_identifier) @name) @definition.method
; Initializers
(init_declaration) @definition.constructor
; Properties (stored and computed)
(property_declaration (pattern (simple_identifier) @name)) @definition.property
; Imports
(import_declaration (identifier (simple_identifier) @import.source)) @import
; Calls - direct function calls
(call_expression (simple_identifier) @call.name) @call
; Calls - member/navigation calls (obj.method())
(call_expression (navigation_expression (navigation_suffix (simple_identifier) @call.name))) @call
; Heritage - class/struct/enum inheritance and protocol conformance
(class_declaration name: (type_identifier) @heritage.class
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
; Heritage - protocol inheritance
(protocol_declaration name: (type_identifier) @heritage.class
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
`;
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES,
[SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES,
@@ -407,5 +460,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
[SupportedLanguages.Rust]: RUST_QUERIES,
[SupportedLanguages.PHP]: PHP_QUERIES,
[SupportedLanguages.Swift]: SWIFT_QUERIES,
};
+2
View File
@@ -31,6 +31,8 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
filename.endsWith('.php5') || filename.endsWith('.php8')) {
return SupportedLanguages.PHP;
}
// Swift
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
return null;
};
@@ -40,6 +40,7 @@ const getWasmPath = (language: SupportedLanguages, filePath?: string): string =>
[SupportedLanguages.Go]: '/wasm/go/tree-sitter-go.wasm',
[SupportedLanguages.Rust]: '/wasm/rust/tree-sitter-rust.wasm',
[SupportedLanguages.PHP]: '/wasm/php/tree-sitter-php.wasm',
[SupportedLanguages.Swift]: '/wasm/swift/tree-sitter-swift.wasm',
};
return languageFileMap[language];
@@ -69,7 +69,9 @@ export async function fetchRepoInfo(baseUrl: string, repoName?: string): Promise
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
}
return response.json();
const data = await response.json();
// npm gitnexus@1.3.3 returns "path"; git HEAD returns "repoPath"
return { ...data, repoPath: data.repoPath ?? data.path };
}
export async function fetchGraph(
+1 -1
View File
@@ -156,7 +156,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Swift
## Agent Skills
+28 -8
View File
@@ -101,20 +101,40 @@ function main() {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
// Resolve CLI path relative to this hook script (same package)
// hooks/claude/gitnexus-hook.cjs → dist/cli/index.js
const cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
// Resolve CLI path — try multiple strategies:
// 1. Relative path (works when script is inside npm package)
// 2. require.resolve (works when gitnexus is globally installed)
// 3. Fall back to npx (works when neither is available)
let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');
if (!fs.existsSync(cliPath)) {
try {
cliPath = require.resolve('gitnexus/dist/cli/index.js');
} catch {
cliPath = ''; // will use npx fallback
}
}
// augment CLI writes result to stderr (KuzuDB's native module captures
// stdout fd at OS level, making it unusable in subprocess contexts).
const { spawnSync } = require('child_process');
let result = '';
try {
const child = spawnSync(
process.execPath,
[cliPath, 'augment', pattern],
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
let child;
if (cliPath) {
child = spawnSync(
process.execPath,
[cliPath, 'augment', pattern],
{ encoding: 'utf-8', timeout: 8000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
} else {
// npx fallback
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
child = spawnSync(
cmd,
['-y', 'gitnexus', 'augment', pattern],
{ encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }
);
}
result = child.stderr || '';
} catch { /* graceful failure */ }
+2 -1
View File
@@ -63,7 +63,8 @@ if [ "$found" = false ]; then
fi
# Run gitnexus augment — must be fast (<500ms target)
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>/dev/null)
# augment writes to stderr (KuzuDB captures stdout at OS level), so capture stderr and discard stdout
RESULT=$(cd "$CWD" && npx -y gitnexus augment "$PATTERN" 2>&1 1>/dev/null)
if [ -n "$RESULT" ]; then
ESCAPED=$(echo "$RESULT" | jq -Rs .)
+1262 -4
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.3.3",
"version": "1.3.10",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -32,13 +32,20 @@
"files": [
"dist",
"hooks",
"scripts",
"skills",
"vendor"
],
"scripts": {
"build": "tsc",
"dev": "tsx watch src/cli/index.ts",
"prepare": "npm run build"
"test": "vitest run test/unit",
"test:integration": "vitest run test/integration",
"test:all": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "npm run build",
"postinstall": "node scripts/patch-tree-sitter-swift.cjs"
},
"dependencies": {
"@huggingface/transformers": "^3.0.0",
@@ -62,20 +69,26 @@
"tree-sitter-go": "^0.21.0",
"tree-sitter-java": "^0.21.0",
"tree-sitter-javascript": "^0.21.0",
"tree-sitter-kotlin": "^0.3.8",
"tree-sitter-php": "^0.23.12",
"tree-sitter-python": "^0.21.0",
"tree-sitter-rust": "^0.21.0",
"tree-sitter-typescript": "^0.21.0",
"typescript": "^5.4.5",
"uuid": "^13.0.0"
},
"optionalDependencies": {
"tree-sitter-swift": "^0.6.0"
},
"devDependencies": {
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.0.0"
"@vitest/coverage-v8": "^4.0.18",
"tsx": "^4.0.0",
"typescript": "^5.4.5",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=18.0.0"
@@ -0,0 +1,74 @@
#!/usr/bin/env node
/**
* WORKAROUND: tree-sitter-swift@0.6.0 binding.gyp build failure
*
* Background:
* tree-sitter-swift@0.6.0's binding.gyp contains an "actions" array that
* invokes `tree-sitter generate` to regenerate parser.c from grammar.js.
* This is intended for grammar developers, but the published npm package
* already ships pre-generated parser files (parser.c, scanner.c), so the
* actions are unnecessary for consumers. Since consumers don't have
* tree-sitter-cli installed, the actions always fail during `npm install`.
*
* Why we can't just upgrade:
* tree-sitter-swift@0.7.1 fixes this (removes postinstall, ships prebuilds),
* but it requires tree-sitter@^0.22.1. The upstream project pins tree-sitter
* to ^0.21.0 and all other grammar packages depend on that version.
* Upgrading tree-sitter would be a separate breaking change.
*
* How this workaround works:
* 1. tree-sitter-swift's own postinstall fails (npm warns but continues)
* 2. This script runs as gitnexus's postinstall
* 3. It removes the "actions" array from binding.gyp
* 4. It rebuilds the native binding with the cleaned binding.gyp
*
* TODO: Remove this script when tree-sitter is upgraded to ^0.22.x,
* which allows using tree-sitter-swift@0.7.1+ directly.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const swiftDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-swift');
const bindingPath = path.join(swiftDir, 'binding.gyp');
try {
if (!fs.existsSync(bindingPath)) {
process.exit(0);
}
const content = fs.readFileSync(bindingPath, 'utf8');
let needsRebuild = false;
if (content.includes('"actions"')) {
// Strip Python-style comments (#) before JSON parsing
const cleaned = content.replace(/#[^\n]*/g, '');
const gyp = JSON.parse(cleaned);
if (gyp.targets && gyp.targets[0] && gyp.targets[0].actions) {
delete gyp.targets[0].actions;
fs.writeFileSync(bindingPath, JSON.stringify(gyp, null, 2) + '\n');
console.log('[tree-sitter-swift] Patched binding.gyp (removed actions array)');
needsRebuild = true;
}
}
// Check if native binding exists
const bindingNode = path.join(swiftDir, 'build', 'Release', 'tree_sitter_swift_binding.node');
if (!fs.existsSync(bindingNode)) {
needsRebuild = true;
}
if (needsRebuild) {
console.log('[tree-sitter-swift] Rebuilding native binding...');
execSync('npx node-gyp rebuild', {
cwd: swiftDir,
stdio: 'pipe',
timeout: 120000,
});
console.log('[tree-sitter-swift] Native binding built successfully');
}
} catch (err) {
console.warn('[tree-sitter-swift] Could not build native binding:', err.message);
console.warn('[tree-sitter-swift] You may need to manually run: cd node_modules/tree-sitter-swift && npx node-gyp rebuild');
}
+163
View File
@@ -0,0 +1,163 @@
---
name: gitnexus-pr-review
description: "Use when the user wants to review a pull request, understand what a PR changes, assess risk of merging, or check for missing test coverage. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\""
---
# PR Review with GitNexus
## When to Use
- "Review this PR"
- "What does PR #42 change?"
- "Is this safe to merge?"
- "What's the blast radius of this PR?"
- "Are there missing tests for this PR?"
- Reviewing someone else's code changes before merge
## Workflow
```
1. gh pr diff <number> → Get the raw diff
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"}) → Map diff to affected flows
3. For each changed symbol:
gitnexus_impact({target: "<symbol>", direction: "upstream"}) → Blast radius per change
4. gitnexus_context({name: "<key symbol>"}) → Understand callers/callees
5. READ gitnexus://repo/{name}/processes → Check affected execution flows
6. Summarize findings with risk assessment
```
> If "Index is stale" → run `npx gitnexus analyze` in terminal before reviewing.
## Checklist
```
- [ ] Fetch PR diff (gh pr diff or git diff base...head)
- [ ] gitnexus_detect_changes to map changes to affected execution flows
- [ ] gitnexus_impact on each non-trivial changed symbol
- [ ] Review d=1 items (WILL BREAK) — are callers updated?
- [ ] gitnexus_context on key changed symbols to understand full picture
- [ ] Check if affected processes have test coverage
- [ ] Assess overall risk level
- [ ] Write review summary with findings
```
## Review Dimensions
| Dimension | How GitNexus Helps |
| --- | --- |
| **Correctness** | `context` shows callers — are they all compatible with the change? |
| **Blast radius** | `impact` shows d=1/d=2/d=3 dependents — anything missed? |
| **Completeness** | `detect_changes` shows all affected flows — are they all handled? |
| **Test coverage** | `impact({includeTests: true})` shows which tests touch changed code |
| **Breaking changes** | d=1 upstream items that aren't updated in the PR = potential breakage |
## Risk Assessment
| Signal | Risk |
| --- | --- |
| Changes touch <3 symbols, 0-1 processes | LOW |
| Changes touch 3-10 symbols, 2-5 processes | MEDIUM |
| Changes touch >10 symbols or many processes | HIGH |
| Changes touch auth, payments, or data integrity code | CRITICAL |
| d=1 callers exist outside the PR diff | Potential breakage — flag it |
## Tools
**gitnexus_detect_changes** — map PR diff to affected execution flows:
```
gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed: 8 symbols in 4 files
→ Affected processes: CheckoutFlow, RefundFlow, WebhookHandler
→ Risk: MEDIUM
```
**gitnexus_impact** — blast radius per changed symbol:
```
gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1 (WILL BREAK):
- processCheckout (src/checkout.ts:42) [CALLS, 100%]
- webhookHandler (src/webhooks.ts:15) [CALLS, 100%]
→ d=2 (LIKELY AFFECTED):
- checkoutRouter (src/routes/checkout.ts:22) [CALLS, 95%]
```
**gitnexus_impact with tests** — check test coverage:
```
gitnexus_impact({target: "validatePayment", direction: "upstream", includeTests: true})
→ Tests that cover this symbol:
- validatePayment.test.ts [direct]
- checkout.integration.test.ts [via processCheckout]
```
**gitnexus_context** — understand a changed symbol's role:
```
gitnexus_context({name: "validatePayment"})
→ Incoming calls: processCheckout, webhookHandler
→ Outgoing calls: verifyCard, fetchRates
→ Processes: CheckoutFlow (step 3/7), RefundFlow (step 1/5)
```
## Example: "Review PR #42"
```
1. gh pr diff 42 > /tmp/pr42.diff
→ 4 files changed: payments.ts, checkout.ts, types.ts, utils.ts
2. gitnexus_detect_changes({scope: "compare", base_ref: "main"})
→ Changed symbols: validatePayment, PaymentInput, formatAmount
→ Affected processes: CheckoutFlow, RefundFlow
→ Risk: MEDIUM
3. gitnexus_impact({target: "validatePayment", direction: "upstream"})
→ d=1: processCheckout, webhookHandler (WILL BREAK)
→ webhookHandler is NOT in the PR diff — potential breakage!
4. gitnexus_impact({target: "PaymentInput", direction: "upstream"})
→ d=1: validatePayment (in PR), createPayment (NOT in PR)
→ createPayment uses the old PaymentInput shape — breaking change!
5. gitnexus_context({name: "formatAmount"})
→ Called by 12 functions — but change is backwards-compatible (added optional param)
6. Review summary:
- MEDIUM risk — 3 changed symbols affect 2 execution flows
- BUG: webhookHandler calls validatePayment but isn't updated for new signature
- BUG: createPayment depends on PaymentInput type which changed
- OK: formatAmount change is backwards-compatible
- Tests: checkout.test.ts covers processCheckout path, but no webhook test
```
## Review Output Format
Structure your review as:
```markdown
## PR Review: <title>
**Risk: LOW / MEDIUM / HIGH / CRITICAL**
### Changes Summary
- <N> symbols changed across <M> files
- <P> execution flows affected
### Findings
1. **[severity]** Description of finding
- Evidence from GitNexus tools
- Affected callers/flows
### Missing Coverage
- Callers not updated in PR: ...
- Untested flows: ...
### Recommendation
APPROVE / REQUEST CHANGES / NEEDS DISCUSSION
```
+78 -24
View File
@@ -28,38 +28,92 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
/**
* Generate the full GitNexus context content.
*
* Design principles (learned from real agent behavior):
* - AGENTS.md is the ROUTER — it tells the agent WHICH skill to read
* - Skills contain the actual workflows — AGENTS.md does NOT duplicate them
* - Bold **IMPORTANT** block + "Skills — Read First" heading — agents skip soft suggestions
* - One-line quick start (read context resource) gives agents an entry point
* - Tools/Resources sections are labeled "Reference" — agents treat them as lookup, not workflow
*
* Design principles (learned from real agent behavior and industry research):
* - Inline critical workflows — skills are skipped 56% of the time (Vercel eval data)
* - Use RFC 2119 language (MUST, NEVER, ALWAYS) — models follow imperative rules
* - Three-tier boundaries (Always/When/Never) — proven to change model behavior
* - Keep under 120 lines — adherence degrades past 150 lines
* - Exact tool commands with parameters — vague directives get ignored
* - Self-review checklist — forces model to verify its own work
*/
function generateGitNexusContent(projectName: string, stats: RepoStats): string {
return `${GITNEXUS_START_MARKER}
# GitNexus MCP
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows).
This project is indexed by GitNexus as **${projectName}** (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
## Always Start Here
> If any GitNexus tool warns the index is stale, run \`npx gitnexus analyze\` in terminal first.
1. **Read \`gitnexus://repo/{name}/context\`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**
## Always Do
> If step 1 warns the index is stale, run \`npx gitnexus analyze\` in the terminal first.
- **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"})\`.
## Skills
## When Debugging
| 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\` |
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/${projectName}/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/${projectName}/context\` | Codebase overview, check index freshness |
| \`gitnexus://repo/${projectName}/clusters\` | All functional areas |
| \`gitnexus://repo/${projectName}/processes\` | All execution flows |
| \`gitnexus://repo/${projectName}/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
## CLI
- Re-index: \`npx gitnexus analyze\`
- Check freshness: \`npx gitnexus status\`
- Generate docs: \`npx gitnexus wiki\`
${GITNEXUS_END_MARKER}`;
}
@@ -100,7 +154,7 @@ async function upsertGitNexusSection(
const startIdx = existingContent.indexOf(GITNEXUS_START_MARKER);
const endIdx = existingContent.indexOf(GITNEXUS_END_MARKER);
if (startIdx !== -1 && endIdx !== -1) {
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
// Replace existing section
const before = existingContent.substring(0, startIdx);
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
+5 -13
View File
@@ -18,7 +18,7 @@ import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getG
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
import { generateAIContextFiles } from './ai-context.js';
import fs from 'fs/promises';
import { registerClaudeHook } from './claude-hooks.js';
const HEAP_MB = 8192;
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
@@ -292,8 +292,6 @@ export const analyzeCommand = async (
await registerRepo(repoPath, meta);
await addToGitignore(repoPath);
const hookResult = await registerClaudeHook();
const projectName = path.basename(repoPath);
let aggregatedClusterCount = 0;
if (pipelineResult.communityResult?.communities) {
@@ -342,10 +340,6 @@ export const analyzeCommand = async (
console.log(` Context: ${aiContext.files.join(', ')}`);
}
if (hookResult.registered) {
console.log(` Hooks: ${hookResult.message}`);
}
// Show a quiet summary if some edge types needed fallback insertion
if (kuzuWarnings.length > 0) {
const totalFallback = kuzuWarnings.reduce((sum, w) => {
@@ -363,10 +357,8 @@ export const analyzeCommand = async (
console.log('');
// ONNX Runtime registers native atexit hooks that segfault during process
// shutdown on macOS (#38) and some Linux configs (#40). Force-exit to
// bypass them when embeddings were loaded.
if (!embeddingSkipped) {
process.exit(0);
}
// KuzuDB's native module holds open handles that prevent Node from exiting.
// ONNX Runtime also registers native atexit hooks that segfault on some
// platforms (#38, #40). Force-exit to ensure clean termination.
process.exit(0);
};
-111
View File
@@ -1,111 +0,0 @@
/**
* Claude Code Hook Registration
*
* Registers the GitNexus PreToolUse hook in ~/.claude/hooks.json
* so that grep/glob/bash calls are automatically augmented with
* knowledge graph context.
*
* Idempotent — safe to call multiple times.
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Get the absolute path to the gitnexus-hook.js file.
* Works for both local dev and npm-installed packages.
*/
function getHookScriptPath(): string {
// From dist/cli/claude-hooks.js → hooks/claude/gitnexus-hook.js
const packageRoot = path.resolve(__dirname, '..', '..');
return path.join(packageRoot, 'hooks', 'claude', 'gitnexus-hook.cjs');
}
/**
* Register (or verify) the GitNexus hook in Claude Code's global hooks.json.
*
* - Creates ~/.claude/ and hooks.json if they don't exist
* - Preserves existing hooks from other tools
* - Skips if GitNexus hook is already registered
*
* Returns a status message for the CLI output.
*/
export async function registerClaudeHook(): Promise<{ registered: boolean; message: string }> {
const claudeDir = path.join(os.homedir(), '.claude');
const hooksFile = path.join(claudeDir, 'hooks.json');
const hookScript = getHookScriptPath();
// Check if the hook script exists
try {
await fs.access(hookScript);
} catch {
return { registered: false, message: 'Hook script not found (package may be incomplete)' };
}
// Build the hook command — use node + absolute path for reliability
const hookCommand = `node "${hookScript}"`;
// Check if ~/.claude/ exists (user has Claude Code installed)
try {
await fs.access(claudeDir);
} catch {
// No Claude Code installation — skip silently
return { registered: false, message: 'Claude Code not detected (~/.claude/ not found)' };
}
// Read existing hooks.json or start fresh
let hooksConfig: any = {};
try {
const existing = await fs.readFile(hooksFile, 'utf-8');
hooksConfig = JSON.parse(existing);
} catch {
// File doesn't exist or is invalid — we'll create it
}
// Ensure the hooks structure exists
if (!hooksConfig.hooks) {
hooksConfig.hooks = {};
}
if (!Array.isArray(hooksConfig.hooks.PreToolUse)) {
hooksConfig.hooks.PreToolUse = [];
}
// Check if GitNexus hook is already registered
const existingEntry = hooksConfig.hooks.PreToolUse.find((entry: any) => {
if (!entry.hooks || !Array.isArray(entry.hooks)) return false;
return entry.hooks.some((h: any) =>
h.command && (
h.command.includes('gitnexus-hook') ||
h.command.includes('gitnexus augment')
)
);
});
if (existingEntry) {
return { registered: true, message: 'Claude Code hook already registered' };
}
// Add the GitNexus hook entry
hooksConfig.hooks.PreToolUse.push({
matcher: {
tool_name: "Grep|Glob|Bash"
},
hooks: [
{
type: "command",
command: hookCommand,
timeout: 8000
}
]
});
// Write back
await fs.writeFile(hooksFile, JSON.stringify(hooksConfig, null, 2) + '\n', 'utf-8');
return { registered: true, message: 'Claude Code hook registered' };
}
+17 -7
View File
@@ -36,7 +36,7 @@ export interface EvalServerOptions {
// Convert structured JSON results into compact, LLM-friendly text.
// Design: minimize tokens, maximize actionability.
function formatQueryResult(result: any): string {
export function formatQueryResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const lines: string[] = [];
@@ -77,7 +77,7 @@ function formatQueryResult(result: any): string {
return lines.join('\n').trim();
}
function formatContextResult(result: any): string {
export function formatContextResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (result.status === 'ambiguous') {
@@ -141,7 +141,7 @@ function formatContextResult(result: any): string {
return lines.join('\n').trim();
}
function formatImpactResult(result: any): string {
export function formatImpactResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const target = result.target;
@@ -181,7 +181,7 @@ function formatImpactResult(result: any): string {
return lines.join('\n').trim();
}
function formatCypherResult(result: any): string {
export function formatCypherResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
if (Array.isArray(result)) {
@@ -202,7 +202,7 @@ function formatCypherResult(result: any): string {
return typeof result === 'string' ? result : JSON.stringify(result, null, 2);
}
function formatDetectChangesResult(result: any): string {
export function formatDetectChangesResult(result: any): string {
if (result.error) return `Error: ${result.error}`;
const summary = result.summary || {};
@@ -238,7 +238,7 @@ function formatDetectChangesResult(result: any): string {
return lines.join('\n').trim();
}
function formatListReposResult(result: any): string {
export function formatListReposResult(result: any): string {
if (!Array.isArray(result) || result.length === 0) {
return 'No indexed repositories.';
}
@@ -420,10 +420,20 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
process.on('SIGTERM', shutdown);
}
export const MAX_BODY_SIZE = 1024 * 1024; // 1MB
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
let totalSize = 0;
req.on('data', (chunk: Buffer) => {
totalSize += chunk.length;
if (totalSize > MAX_BODY_SIZE) {
req.destroy(new Error('Request body too large (max 1MB)'));
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
+22 -45
View File
@@ -1,84 +1,61 @@
#!/usr/bin/env node
// Raise Node heap limit for large repos (e.g. Linux kernel).
// Must run before any heavy allocation. If already set by the user, respect it.
if (!process.env.NODE_OPTIONS?.includes('--max-old-space-size')) {
const execArgv = process.execArgv.join(' ');
if (!execArgv.includes('--max-old-space-size')) {
// Re-spawn with a larger heap (8 GB)
const { execFileSync } = await import('node:child_process');
try {
execFileSync(process.execPath, ['--max-old-space-size=8192', ...process.argv.slice(1)], {
stdio: 'inherit',
env: { ...process.env, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim() },
});
process.exit(0);
} catch (e: any) {
// If the child exited with an error code, propagate it
process.exit(e.status ?? 1);
}
}
}
// Heap re-spawn removed — only analyze.ts needs the 8GB heap (via its own ensureHeap()).
// Removing it from here improves MCP server startup time significantly.
import { Command } from 'commander';
import { analyzeCommand } from './analyze.js';
import { serveCommand } from './serve.js';
import { listCommand } from './list.js';
import { statusCommand } from './status.js';
import { mcpCommand } from './mcp.js';
import { cleanCommand } from './clean.js';
import { setupCommand } from './setup.js';
import { augmentCommand } from './augment.js';
import { wikiCommand } from './wiki.js';
import { queryCommand, contextCommand, impactCommand, cypherCommand } from './tool.js';
import { evalServerCommand } from './eval-server.js';
import { createRequire } from 'node:module';
import { createLazyAction } from './lazy-action.js';
const _require = createRequire(import.meta.url);
const pkg = _require('../../package.json');
const program = new Command();
program
.name('gitnexus')
.description('GitNexus local CLI and MCP server')
.version('1.2.0');
.version(pkg.version);
program
.command('setup')
.description('One-time setup: configure MCP for Cursor, Claude Code, OpenCode')
.action(setupCommand);
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
program
.command('analyze [path]')
.description('Index a repository (full analysis)')
.option('-f, --force', 'Force full re-index even if up to date')
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
.action(analyzeCommand);
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
program
.command('serve')
.description('Start local HTTP server for web UI connection')
.option('-p, --port <port>', 'Port number', '4747')
.option('--host <host>', 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)')
.action(serveCommand);
.action(createLazyAction(() => import('./serve.js'), 'serveCommand'));
program
.command('mcp')
.description('Start MCP server (stdio) — serves all indexed repos')
.action(mcpCommand);
.action(createLazyAction(() => import('./mcp.js'), 'mcpCommand'));
program
.command('list')
.description('List all indexed repositories')
.action(listCommand);
.action(createLazyAction(() => import('./list.js'), 'listCommand'));
program
.command('status')
.description('Show index status for current repo')
.action(statusCommand);
.action(createLazyAction(() => import('./status.js'), 'statusCommand'));
program
.command('clean')
.description('Delete GitNexus index for current repo')
.option('-f, --force', 'Skip confirmation prompt')
.option('--all', 'Clean all indexed repos')
.action(cleanCommand);
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
program
.command('wiki [path]')
@@ -89,12 +66,12 @@ program
.option('--api-key <key>', 'LLM API key (saved to ~/.gitnexus/config.json)')
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
.action(wikiCommand);
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
program
.command('augment <pattern>')
.description('Augment a search pattern with knowledge graph context (used by hooks)')
.action(augmentCommand);
.action(createLazyAction(() => import('./augment.js'), 'augmentCommand'));
// ─── Direct Tool Commands (no MCP overhead) ────────────────────────
// These invoke LocalBackend directly for use in eval, scripts, and CI.
@@ -107,7 +84,7 @@ program
.option('-g, --goal <text>', 'What you want to find')
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
.option('--content', 'Include full symbol source code')
.action(queryCommand);
.action(createLazyAction(() => import('./tool.js'), 'queryCommand'));
program
.command('context [name]')
@@ -116,7 +93,7 @@ program
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
.option('-f, --file <path>', 'File path to disambiguate common names')
.option('--content', 'Include full symbol source code')
.action(contextCommand);
.action(createLazyAction(() => import('./tool.js'), 'contextCommand'));
program
.command('impact <target>')
@@ -125,13 +102,13 @@ program
.option('-r, --repo <name>', 'Target repository')
.option('--depth <n>', 'Max relationship depth (default: 3)')
.option('--include-tests', 'Include test files in results')
.action(impactCommand);
.action(createLazyAction(() => import('./tool.js'), 'impactCommand'));
program
.command('cypher <query>')
.description('Execute raw Cypher query against the knowledge graph')
.option('-r, --repo <name>', 'Target repository')
.action(cypherCommand);
.action(createLazyAction(() => import('./tool.js'), 'cypherCommand'));
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
@@ -140,6 +117,6 @@ program
.description('Start lightweight HTTP server for fast tool calls during evaluation')
.option('-p, --port <port>', 'Port number', '4848')
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
.action(evalServerCommand);
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
program.parse(process.argv);
+26
View File
@@ -0,0 +1,26 @@
/**
* Creates a lazy-loaded CLI action that defers module import until invocation.
* The generic constraints ensure the export name is a valid key of the module
* at compile time — catching typos when used with concrete module imports.
*/
function isCallable(value: unknown): value is (...args: unknown[]) => unknown {
return typeof value === 'function';
}
export function createLazyAction<
TModule extends Record<string, unknown>,
TKey extends string & keyof TModule,
>(
loader: () => Promise<TModule>,
exportName: TKey,
): (...args: unknown[]) => Promise<void> {
return async (...args: unknown[]): Promise<void> => {
const module = await loader();
const action = module[exportName];
if (!isCallable(action)) {
throw new Error(`Lazy action export not found: ${exportName}`);
}
await action(...args);
};
}
+12 -25
View File
@@ -8,46 +8,33 @@
import { startMCPServer } from '../mcp/server.js';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { listRegisteredRepos } from '../storage/repo-manager.js';
export const mcpCommand = async () => {
// Prevent unhandled errors from crashing the MCP server process.
// KuzuDB lock conflicts and transient errors should degrade gracefully.
process.on('uncaughtException', (err) => {
console.error(`GitNexus MCP: uncaught exception — ${err.message}`);
// Process is in an undefined state after uncaughtException — exit after flushing
setTimeout(() => process.exit(1), 100);
});
process.on('unhandledRejection', (reason) => {
const msg = reason instanceof Error ? reason.message : String(reason);
console.error(`GitNexus MCP: unhandled rejection — ${msg}`);
});
// Load all registered repos
const entries = await listRegisteredRepos({ validate: true });
if (entries.length === 0) {
console.error('');
console.error(' GitNexus: No indexed repositories found.');
console.error('');
console.error(' To get started:');
console.error(' 1. cd into a git repository');
console.error(' 2. Run: gitnexus analyze');
console.error(' 3. Restart your editor');
console.error('');
process.exit(1);
}
// Initialize multi-repo backend from registry
// Initialize multi-repo backend from registry.
// The server starts even with 0 repos — tools call refreshRepos() lazily,
// so repos indexed after the server starts are discovered automatically.
const backend = new LocalBackend();
const ok = await backend.init();
await backend.init();
if (!ok) {
console.error('GitNexus: Failed to initialize backend from registry.');
process.exit(1);
const repos = await backend.listRepos();
if (repos.length === 0) {
console.error('GitNexus: No indexed repos yet. Run `gitnexus analyze` in a git repo — the server will pick it up automatically.');
} else {
console.error(`GitNexus: MCP server starting with ${repos.length} repo(s): ${repos.map(r => r.name).join(', ')}`);
}
const repoNames = (await backend.listRepos()).map(r => r.name);
console.error(`GitNexus: MCP server starting with ${repoNames.length} repo(s): ${repoNames.join(', ')}`);
// Start MCP server (serves all repos)
// Start MCP server (serves all repos, discovers new ones lazily)
await startMCPServer(backend);
};
+9 -1
View File
@@ -163,7 +163,15 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
const src = path.join(pluginHooksPath, 'gitnexus-hook.cjs');
const dest = path.join(destHooksDir, 'gitnexus-hook.cjs');
try {
const content = await fs.readFile(src, 'utf-8');
let content = await fs.readFile(src, 'utf-8');
// Inject resolved CLI path so the copied hook can find the CLI
// even when it's no longer inside the npm package tree
const resolvedCli = path.join(__dirname, '..', 'cli', 'index.js');
const normalizedCli = path.resolve(resolvedCli).replace(/\\/g, '/');
content = content.replace(
"let cliPath = path.resolve(__dirname, '..', '..', 'dist', 'cli', 'index.js');",
`let cliPath = '${normalizedCli}';`
);
await fs.writeFile(dest, content, 'utf-8');
} catch {
// Script not found in source — skip
+6 -5
View File
@@ -7,7 +7,7 @@
import path from 'path';
import readline from 'readline';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import cliProgress from 'cli-progress';
import { getGitRoot, isGitRepo } from '../storage/git.js';
import { getStoragePaths, loadMeta, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js';
@@ -343,10 +343,11 @@ function hasGhCLI(): boolean {
function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
try {
const output = execSync(
`gh gist create "${htmlPath}" --desc "Repository Wiki — generated by GitNexus" --public`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
const output = execFileSync('gh', [
'gist', 'create', htmlPath,
'--desc', 'Repository Wiki — generated by GitNexus',
'--public',
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
// gh gist create prints the gist URL as the last line
const lines = output.split('\n');
+2 -1
View File
@@ -9,6 +9,7 @@ export enum SupportedLanguages {
Go = 'go',
Rust = 'rust',
PHP = 'php',
Kotlin = 'kotlin',
// Ruby = 'ruby',
// Swift = 'swift',
Swift = 'swift',
}
+4 -1
View File
@@ -42,6 +42,9 @@ export type NodeProperties = {
endLine?: number,
language?: string,
isExported?: boolean,
// Optional AST-derived framework hint (e.g. @Controller, @GetMapping)
astFrameworkMultiplier?: number,
astFrameworkReason?: string,
// Community-specific properties
heuristicLabel?: string,
cohesion?: number,
@@ -113,4 +116,4 @@ export interface KnowledgeGraph {
addRelationship: (relationship: GraphRelationship) => void,
removeNode: (nodeId: string) => boolean,
removeNodesByFile: (filePath: string) => number,
}
}
+3 -2
View File
@@ -10,10 +10,11 @@ export interface ASTCache {
}
export const createASTCache = (maxSize: number = 50): ASTCache => {
const effectiveMax = Math.max(maxSize, 1);
// Initialize the cache with a 'dispose' handler
// This is the magic: When an item is evicted (dropped), this runs automatically.
const cache = new LRUCache<string, Parser.Tree>({
max: maxSize,
max: effectiveMax,
dispose: (tree) => {
try {
// NOTE: web-tree-sitter has tree.delete(); native tree-sitter trees are GC-managed.
@@ -41,7 +42,7 @@ export const createASTCache = (maxSize: number = 50): ASTCache => {
stats: () => ({
size: cache.size,
maxSize: maxSize
maxSize: effectiveMax
})
};
};
+133 -2
View File
@@ -7,7 +7,7 @@ import { loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import type { ExtractedCall } from './workers/parse-worker.js';
import type { ExtractedCall, ExtractedRoute } from './workers/parse-worker.js';
/**
* Node types that represent function/method definitions across languages.
@@ -37,6 +37,13 @@ const FUNCTION_NODE_TYPES = new Set([
// Rust
'function_item',
'impl_item', // Methods inside impl blocks
// Kotlin (function_declaration already included above via JS/TS)
'anonymous_function',
'lambda_literal',
// PHP — no additional node types needed
// Swift
'init_declaration',
'deinit_declaration',
]);
/**
@@ -57,7 +64,13 @@ const findEnclosingFunction = (
let label = 'Function';
// Different node types have different name locations
if (current.type === 'function_declaration' ||
// Swift init/deinit — handle before generic cases (more specific)
if (current.type === 'init_declaration' || current.type === 'deinit_declaration') {
const funcName = current.type === 'init_declaration' ? 'init' : 'deinit';
return generateId('Constructor', `${filePath}:${funcName}`);
}
if (current.type === 'function_declaration' ||
current.type === 'function_definition' ||
current.type === 'async_function_declaration' ||
current.type === 'generator_function_declaration' ||
@@ -315,6 +328,22 @@ const BUILT_IN_NAMES = new Set([
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
// Kotlin stdlib (IMPORTANT: keep in sync with parse-worker.ts BUILT_IN_NAMES)
'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error',
'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf',
'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless',
'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet',
'repeat', 'synchronized',
// Kotlin coroutine builders & scope functions
'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope',
'supervisorScope', 'delay',
// Kotlin Flow operators
'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch',
'buffer', 'conflate', 'distinctUntilChanged',
'flatMapLatest', 'flatMapMerge', 'combine',
'stateIn', 'shareIn', 'launchIn',
// Kotlin infix stdlib functions
'to', 'until', 'downTo', 'step',
// C/C++ standard library and common kernel helpers
'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf',
'scanf', 'fscanf', 'sscanf',
@@ -336,6 +365,37 @@ const BUILT_IN_NAMES = new Set([
'mutex_lock', 'mutex_unlock', 'mutex_init',
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
'get', 'put',
// Swift/iOS built-ins and standard library
'print', 'debugPrint', 'dump', 'fatalError', 'precondition', 'preconditionFailure',
'assert', 'assertionFailure', 'NSLog',
'abs', 'min', 'max', 'zip', 'stride', 'sequence', 'repeatElement',
'swap', 'withUnsafePointer', 'withUnsafeMutablePointer', 'withUnsafeBytes',
'autoreleasepool', 'unsafeBitCast', 'unsafeDowncast', 'numericCast',
'type', 'MemoryLayout',
// Swift collection/string methods (common noise)
'map', 'flatMap', 'compactMap', 'filter', 'reduce', 'forEach', 'contains',
'first', 'last', 'prefix', 'suffix', 'dropFirst', 'dropLast',
'sorted', 'reversed', 'enumerated', 'joined', 'split',
'append', 'insert', 'remove', 'removeAll', 'removeFirst', 'removeLast',
'isEmpty', 'count', 'index', 'startIndex', 'endIndex',
// UIKit/Foundation common methods (noise in call graph)
'addSubview', 'removeFromSuperview', 'layoutSubviews', 'setNeedsLayout',
'layoutIfNeeded', 'setNeedsDisplay', 'invalidateIntrinsicContentSize',
'addTarget', 'removeTarget', 'addGestureRecognizer',
'addConstraint', 'addConstraints', 'removeConstraint', 'removeConstraints',
'NSLocalizedString', 'Bundle',
'reloadData', 'reloadSections', 'reloadRows', 'performBatchUpdates',
'register', 'dequeueReusableCell', 'dequeueReusableSupplementaryView',
'beginUpdates', 'endUpdates', 'insertRows', 'deleteRows', 'insertSections', 'deleteSections',
'present', 'dismiss', 'pushViewController', 'popViewController', 'popToRootViewController',
'performSegue', 'prepare',
// GCD / async
'DispatchQueue', 'async', 'sync', 'asyncAfter',
'Task', 'withCheckedContinuation', 'withCheckedThrowingContinuation',
// Combine
'sink', 'store', 'assign', 'receive', 'subscribe',
// Notification / KVO
'addObserver', 'removeObserver', 'post', 'NotificationCenter',
]);
const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);
@@ -396,3 +456,74 @@ export const processCallsFromExtracted = async (
onProgress?.(totalFiles, totalFiles);
};
/**
* Resolve pre-extracted Laravel routes to CALLS edges from route files to controller methods.
*/
export const processRoutesFromExtracted = async (
graph: KnowledgeGraph,
extractedRoutes: ExtractedRoute[],
symbolTable: SymbolTable,
importMap: ImportMap,
onProgress?: (current: number, total: number) => void
) => {
for (let i = 0; i < extractedRoutes.length; i++) {
const route = extractedRoutes[i];
if (i % 50 === 0) {
onProgress?.(i, extractedRoutes.length);
await yieldToEventLoop();
}
if (!route.controllerName || !route.methodName) continue;
// Resolve controller class in symbol table
const controllerDefs = symbolTable.lookupFuzzy(route.controllerName);
if (controllerDefs.length === 0) continue;
// Prefer import-resolved match
const importedFiles = importMap.get(route.filePath);
let controllerDef = controllerDefs[0];
let confidence = controllerDefs.length === 1 ? 0.7 : 0.5;
if (importedFiles) {
for (const def of controllerDefs) {
if (importedFiles.has(def.filePath)) {
controllerDef = def;
confidence = 0.9;
break;
}
}
}
// Find the method on the controller
const methodId = symbolTable.lookupExact(controllerDef.filePath, route.methodName);
const sourceId = generateId('File', route.filePath);
if (!methodId) {
// Construct method ID manually
const guessedId = generateId('Method', `${controllerDef.filePath}:${route.methodName}`);
const relId = generateId('CALLS', `${sourceId}:route->${guessedId}`);
graph.addRelationship({
id: relId,
sourceId,
targetId: guessedId,
type: 'CALLS',
confidence: confidence * 0.8,
reason: 'laravel-route',
});
continue;
}
const relId = generateId('CALLS', `${sourceId}:route->${methodId}`);
graph.addRelationship({
id: relId,
sourceId,
targetId: methodId,
type: 'CALLS',
confidence,
reason: 'laravel-route',
});
}
onProgress?.(extractedRoutes.length, extractedRoutes.length);
};
@@ -103,6 +103,26 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
/^Start$/, // Start methods
],
// Swift / iOS
'swift': [
/^viewDidLoad$/, // UIKit lifecycle
/^viewWillAppear$/, // UIKit lifecycle
/^viewDidAppear$/, // UIKit lifecycle
/^viewWillDisappear$/, // UIKit lifecycle
/^viewDidDisappear$/, // UIKit lifecycle
/^application\(/, // AppDelegate methods
/^scene\(/, // SceneDelegate methods
/^body$/, // SwiftUI View.body
/Coordinator$/, // Coordinator pattern
/^sceneDidBecomeActive$/, // SceneDelegate lifecycle
/^sceneWillResignActive$/, // SceneDelegate lifecycle
/^didFinishLaunchingWithOptions$/, // AppDelegate
/ViewController$/, // ViewController classes
/^configure[A-Z]/, // Configuration methods
/^setup[A-Z]/, // Setup methods
/^makeBody$/, // SwiftUI ViewModifier
],
// PHP / Laravel
'php': [
/Controller$/, // UserController (class name convention)
@@ -271,6 +291,10 @@ export function isTestFile(filePath: string): boolean {
p.includes('/src/test/') ||
// Rust test patterns (inline tests are different, but test files)
p.includes('/tests/') ||
// Swift/iOS test patterns
p.endsWith('tests.swift') ||
p.endsWith('test.swift') ||
p.includes('uitests/') ||
// C# test patterns
p.includes('.tests/') ||
p.includes('tests.cs') ||
@@ -1,8 +1,10 @@
/**
* Framework Detection
*
* Detects frameworks from file path patterns and provides entry point multipliers.
* This enables framework-aware entry point scoring.
* Detects frameworks from:
* 1) file path patterns
* 2) AST definition text (decorators/annotations/attributes)
* and provides entry point multipliers for process scoring.
*
* DESIGN: Returns null for unknown frameworks, which causes a 1.0 multiplier
* (no bonus, no penalty) - same behavior as before this feature.
@@ -127,6 +129,49 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'java-service', entryPointMultiplier: 1.8, reason: 'java-service' };
}
// ========== KOTLIN FRAMEWORKS ==========
// Spring Boot Kotlin controllers
if ((p.includes('/controller/') || p.includes('/controllers/')) && p.endsWith('.kt')) {
return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller' };
}
// Spring Boot - files ending in Controller.kt
if (p.endsWith('controller.kt')) {
return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller-file' };
}
// Ktor routes
if (p.includes('/routes/') && p.endsWith('.kt')) {
return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routes' };
}
// Ktor plugins folder or Routing.kt files
if (p.includes('/plugins/') && p.endsWith('.kt')) {
return { framework: 'ktor', entryPointMultiplier: 2.0, reason: 'ktor-plugin' };
}
if (p.endsWith('routing.kt') || p.endsWith('routes.kt')) {
return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routing-file' };
}
// Android Activities, Fragments
if ((p.includes('/activity/') || p.includes('/ui/')) && p.endsWith('.kt')) {
return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-ui' };
}
if (p.endsWith('activity.kt') || p.endsWith('fragment.kt')) {
return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-component' };
}
// Kotlin main entry point
if (p.endsWith('/main.kt')) {
return { framework: 'kotlin', entryPointMultiplier: 3.0, reason: 'kotlin-main' };
}
// Kotlin Application entry point (common naming)
if (p.endsWith('/application.kt')) {
return { framework: 'kotlin', entryPointMultiplier: 2.5, reason: 'kotlin-application' };
}
// ========== C# / .NET FRAMEWORKS ==========
// ASP.NET Controllers
@@ -257,6 +302,53 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'laravel', entryPointMultiplier: 1.5, reason: 'laravel-repository' };
}
// ========== SWIFT / iOS ==========
// iOS App entry points (highest priority)
if (p.endsWith('/appdelegate.swift') || p.endsWith('/scenedelegate.swift') || p.endsWith('/app.swift')) {
return { framework: 'ios', entryPointMultiplier: 3.0, reason: 'ios-app-entry' };
}
// SwiftUI App entry (@main)
if (p.endsWith('app.swift') && p.includes('/sources/')) {
return { framework: 'swiftui', entryPointMultiplier: 3.0, reason: 'swiftui-app' };
}
// UIKit ViewControllers (high priority - screen entry points)
if ((p.includes('/viewcontrollers/') || p.includes('/controllers/') || p.includes('/screens/')) && p.endsWith('.swift')) {
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller' };
}
// ViewController by filename convention
if (p.endsWith('viewcontroller.swift') || p.endsWith('vc.swift')) {
return { framework: 'uikit', entryPointMultiplier: 2.5, reason: 'uikit-viewcontroller-file' };
}
// Coordinator pattern (navigation entry points)
if (p.includes('/coordinators/') && p.endsWith('.swift')) {
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator' };
}
// Coordinator by filename
if (p.endsWith('coordinator.swift')) {
return { framework: 'ios-coordinator', entryPointMultiplier: 2.5, reason: 'ios-coordinator-file' };
}
// SwiftUI Views (moderate - reusable components)
if ((p.includes('/views/') || p.includes('/scenes/')) && p.endsWith('.swift')) {
return { framework: 'swiftui', entryPointMultiplier: 1.8, reason: 'swiftui-view' };
}
// Service layer
if (p.includes('/services/') && p.endsWith('.swift')) {
return { framework: 'ios-service', entryPointMultiplier: 1.8, reason: 'ios-service' };
}
// Router / navigation
if (p.includes('/router/') && p.endsWith('.swift')) {
return { framework: 'ios-router', entryPointMultiplier: 2.0, reason: 'ios-router' };
}
// ========== GENERIC PATTERNS ==========
// Any language: index files in API folders
@@ -272,12 +364,12 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
}
// ============================================================================
// FUTURE: AST-BASED PATTERNS (for Phase 3)
// AST-BASED FRAMEWORK DETECTION
// ============================================================================
/**
* Patterns that indicate entry points within code (for future AST-based detection)
* These would require parsing decorators/annotations in the code itself.
* Patterns that indicate framework entry points within code definitions.
* These are matched against AST node text (class/method/function declaration text).
*/
export const FRAMEWORK_AST_PATTERNS = {
// JavaScript/TypeScript decorators
@@ -306,4 +398,85 @@ export const FRAMEWORK_AST_PATTERNS = {
'actix': ['#[get', '#[post', '#[put', '#[delete'],
'axum': ['Router::new'],
'rocket': ['#[get', '#[post'],
// Swift/iOS
'uikit': ['viewDidLoad', 'viewWillAppear', 'viewDidAppear', 'UIViewController'],
'swiftui': ['@main', 'WindowGroup', 'ContentView', '@StateObject', '@ObservedObject'],
'combine': ['sink', 'assign', 'Publisher', 'Subscriber'],
};
interface AstFrameworkPatternConfig {
framework: string;
entryPointMultiplier: number;
reason: string;
patterns: string[];
}
const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConfig[]> = {
javascript: [
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
],
typescript: [
{ framework: 'nestjs', entryPointMultiplier: 3.2, reason: 'nestjs-decorator', patterns: FRAMEWORK_AST_PATTERNS.nestjs },
],
python: [
{ framework: 'fastapi', entryPointMultiplier: 3.0, reason: 'fastapi-decorator', patterns: FRAMEWORK_AST_PATTERNS.fastapi },
{ framework: 'flask', entryPointMultiplier: 2.8, reason: 'flask-decorator', patterns: FRAMEWORK_AST_PATTERNS.flask },
],
java: [
{ framework: 'spring', entryPointMultiplier: 3.2, reason: 'spring-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
],
kotlin: [
{ framework: 'spring-kotlin', entryPointMultiplier: 3.2, reason: 'spring-kotlin-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
{ framework: 'ktor', entryPointMultiplier: 2.8, reason: 'ktor-routing', patterns: ['routing', 'embeddedServer', 'Application.module'] },
{ framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-annotation', patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('] },
],
csharp: [
{ framework: 'aspnet', entryPointMultiplier: 3.2, reason: 'aspnet-attribute', patterns: FRAMEWORK_AST_PATTERNS.aspnet },
],
php: [
{ framework: 'laravel', entryPointMultiplier: 3.0, reason: 'php-route-attribute', patterns: FRAMEWORK_AST_PATTERNS.laravel },
],
};
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
const AST_PATTERNS_LOWERED: Record<string, Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>> =
Object.fromEntries(
Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [
lang,
cfgs.map(cfg => ({ ...cfg, patterns: cfg.patterns.map(p => p.toLowerCase()) })),
])
);
/**
* Detect framework entry points from AST definition text (decorators/annotations/attributes).
* Returns null if no known pattern is found.
* Note: callers should slice definitionText to ~300 chars since annotations appear at the start.
*/
export function detectFrameworkFromAST(
language: string,
definitionText: string
): FrameworkHint | null {
if (!language || !definitionText) return null;
const configs = AST_PATTERNS_LOWERED[language.toLowerCase()];
if (!configs || configs.length === 0) return null;
const normalized = definitionText.toLowerCase();
for (const cfg of configs) {
for (const pattern of cfg.patterns) {
if (normalized.includes(pattern)) {
return {
framework: cfg.framework,
entryPointMultiplier: cfg.entryPointMultiplier,
reason: cfg.reason,
};
}
}
}
return null;
}
+165 -40
View File
@@ -153,6 +153,42 @@ async function loadComposerConfig(repoRoot: string): Promise<ComposerConfig | nu
}
}
/** Swift Package Manager module config */
interface SwiftPackageConfig {
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
targets: Map<string, string>;
}
async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPackageConfig | null> {
// Swift imports are module-name based (e.g., `import SiuperModel`)
// SPM convention: Sources/<TargetName>/ or Package/Sources/<TargetName>/
// We scan for these directories to build a target map
const targets = new Map<string, string>();
const sourceDirs = ['Sources', 'Package/Sources', 'src'];
for (const sourceDir of sourceDirs) {
try {
const fullPath = path.join(repoRoot, sourceDir);
const entries = await fs.readdir(fullPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
targets.set(entry.name, sourceDir + '/' + entry.name);
}
}
} catch {
// Directory doesn't exist
}
}
if (targets.size > 0) {
if (isDev) {
console.log(`📦 Loaded ${targets.size} Swift package targets`);
}
return { targets };
}
return null;
}
// ============================================================================
// IMPORT PATH RESOLUTION
// ============================================================================
@@ -166,6 +202,8 @@ const EXTENSIONS = [
'.py', '/__init__.py',
// Java
'.java',
// Kotlin
'.kt', '.kts',
// C/C++
'.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh',
// C#
@@ -176,6 +214,8 @@ const EXTENSIONS = [
'.rs', '/mod.rs',
// PHP
'.php', '.phtml',
// Swift
'.swift',
];
/**
@@ -493,26 +533,42 @@ function tryRustModulePath(modulePath: string, allFiles: Set<string>): string |
return null;
}
/**
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
* Pure function — returns a new string without mutating the input.
*/
const appendKotlinWildcard = (importPath: string, importNode: any): string => {
for (let i = 0; i < importNode.childCount; i++) {
if (importNode.child(i)?.type === 'wildcard_import') {
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
}
}
return importPath;
};
// ============================================================================
// JAVA MULTI-FILE RESOLUTION
// JVM MULTI-FILE RESOLUTION (Java + Kotlin)
// ============================================================================
/** Kotlin file extensions for JVM resolver reuse */
const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts'];
/**
* Resolve a Java wildcard import (com.example.*) to all matching .java files.
* Returns an array of file paths.
* Resolve a JVM wildcard import (com.example.*) to all matching files.
* Works for both Java (.java) and Kotlin (.kt, .kts).
*/
function resolveJavaWildcard(
function resolveJvmWildcard(
importPath: string,
normalizedFileList: string[],
allFileList: string[],
extensions: readonly string[],
index?: SuffixIndex,
): string[] {
// "com.example.util.*" -> "com/example/util"
const packagePath = importPath.slice(0, -2).replace(/\./g, '/');
if (index) {
// Use directory index: get all .java files in this package directory
const candidates = index.getFilesInDir(packagePath, '.java');
const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext));
// Filter to only direct children (no subdirectories)
const packageSuffix = '/' + packagePath + '/';
return candidates.filter(f => {
@@ -529,7 +585,8 @@ function resolveJavaWildcard(
const matches: string[] = [];
for (let i = 0; i < normalizedFileList.length; i++) {
const normalized = normalizedFileList[i];
if (normalized.includes(packageSuffix) && normalized.endsWith('.java')) {
if (normalized.includes(packageSuffix) &&
extensions.some(ext => normalized.endsWith(ext))) {
const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length);
if (!afterPackage.includes('/')) {
matches.push(allFileList[i]);
@@ -540,36 +597,39 @@ function resolveJavaWildcard(
}
/**
* Try to resolve a Java static import by stripping the member name.
* "com.example.Constants.VALUE" -> resolve "com.example.Constants"
* Try to resolve a JVM member/static import by stripping the member name.
* Java: "com.example.Constants.VALUE" -> resolve "com.example.Constants"
* Kotlin: "com.example.Constants.VALUE" -> resolve "com.example.Constants"
*/
function resolveJavaStaticImport(
function resolveJvmMemberImport(
importPath: string,
normalizedFileList: string[],
allFileList: string[],
extensions: readonly string[],
index?: SuffixIndex,
): string | null {
// Static imports look like: com.example.Constants.VALUE or com.example.Constants.*
// The last segment is a member name (field/method) if it starts with lowercase or is ALL_CAPS
// Member imports: com.example.Constants.VALUE or com.example.Constants.*
// The last segment is a member name if it starts with lowercase, is ALL_CAPS, or is a wildcard
const segments = importPath.split('.');
if (segments.length < 3) return null;
const lastSeg = segments[segments.length - 1];
// If last segment is a wildcard or ALL_CAPS constant or starts with lowercase, strip it
if (lastSeg === '*' || /^[a-z]/.test(lastSeg) || /^[A-Z_]+$/.test(lastSeg)) {
const classPath = segments.slice(0, -1).join('/');
const classSuffix = classPath + '.java';
if (index) {
return index.get(classSuffix) || index.getInsensitive(classSuffix) || null;
}
// Fallback: linear scan
const fullSuffix = '/' + classSuffix;
for (let i = 0; i < normalizedFileList.length; i++) {
if (normalizedFileList[i].endsWith(fullSuffix) ||
normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) {
return allFileList[i];
for (const ext of extensions) {
const classSuffix = classPath + ext;
if (index) {
const result = index.get(classSuffix) || index.getInsensitive(classSuffix);
if (result) return result;
} else {
const fullSuffix = '/' + classSuffix;
for (let i = 0; i < normalizedFileList.length; i++) {
if (normalizedFileList[i].endsWith(fullSuffix) ||
normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) {
return allFileList[i];
}
}
}
}
}
@@ -688,6 +748,7 @@ export const processImports = async (
const tsconfigPaths = await loadTsconfigPaths(effectiveRoot);
const goModule = await loadGoModulePath(effectiveRoot);
const composerConfig = await loadComposerConfig(effectiveRoot);
const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot);
// Helper: add an IMPORTS edge + update import map
const addImportEdge = (filePath: string, resolvedPath: string) => {
@@ -778,26 +839,42 @@ export const processImports = async (
}
// Clean path (remove quotes and angle brackets for C/C++ includes)
const rawImportPath = sourceNode.text.replace(/['"<>]/g, '');
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import'])
: sourceNode.text.replace(/['"<>]/g, '');
totalImportsFound++;
// ---- Java: handle wildcards and static imports specially ----
if (language === SupportedLanguages.Java) {
// ---- JVM languages (Java + Kotlin): handle wildcards and member imports ----
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index);
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
// Kotlin can import Java files in mixed codebases — try .java as fallback
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
for (const matchedFile of javaMatches) {
addImportEdge(file.path, matchedFile);
}
if (javaMatches.length > 0) return;
}
for (const matchedFile of matchedFiles) {
addImportEdge(file.path, matchedFile);
}
return; // skip single-file resolution
}
// Try static import resolution (strip member name)
const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index);
if (staticResolved) {
addImportEdge(file.path, staticResolved);
// Try member/static import resolution (strip member name)
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
// Kotlin can import Java files in mixed codebases — try .java as fallback
if (!memberResolved && language === SupportedLanguages.Kotlin) {
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
}
if (memberResolved) {
addImportEdge(file.path, memberResolved);
return;
}
// Fall through to normal resolution for regular Java imports
// Fall through to normal resolution for regular imports
}
// ---- Go: handle package-level imports ----
@@ -821,6 +898,25 @@ export const processImports = async (
return;
}
// ---- Swift: handle module imports ----
if (language === SupportedLanguages.Swift && swiftPackageConfig) {
// Swift imports are module names: `import SiuperModel`
// Resolve to the module's source directory → all .swift files in it
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
if (targetDir) {
// Find all .swift files in this target directory
const dirPrefix = targetDir + '/';
for (const filePath2 of allFileList) {
if (filePath2.startsWith(dirPrefix) && filePath2.endsWith('.swift')) {
addImportEdge(file.path, filePath2);
}
}
return;
}
// External framework (Foundation, UIKit, etc.) — skip
return;
}
// ---- Standard single-file resolution ----
const resolvedPath = resolveImportPath(
file.path,
@@ -871,6 +967,7 @@ export const processImportsFromExtracted = async (
const tsconfigPaths = await loadTsconfigPaths(effectiveRoot);
const goModule = await loadGoModulePath(effectiveRoot);
const composerConfig = await loadComposerConfig(effectiveRoot);
const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot);
const addImportEdge = (filePath: string, resolvedPath: string) => {
const sourceId = generateId('File', filePath);
@@ -941,20 +1038,34 @@ export const processImportsFromExtracted = async (
continue;
}
// Java: handle wildcards and static imports
if (language === SupportedLanguages.Java) {
// JVM languages (Java + Kotlin): handle wildcards and member imports
if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) {
const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS;
if (rawImportPath.endsWith('.*')) {
const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index);
const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index);
// Kotlin can import Java files in mixed codebases — try .java as fallback
if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) {
const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
for (const matchedFile of javaMatches) {
addImportEdge(filePath, matchedFile);
}
if (javaMatches.length > 0) continue;
}
for (const matchedFile of matchedFiles) {
addImportEdge(filePath, matchedFile);
}
continue;
}
const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index);
if (staticResolved) {
resolveCache.set(cacheKey, staticResolved);
addImportEdge(filePath, staticResolved);
let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
// Kotlin can import Java files in mixed codebases — try .java as fallback
if (!memberResolved && language === SupportedLanguages.Kotlin) {
memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index);
}
if (memberResolved) {
resolveCache.set(cacheKey, memberResolved);
addImportEdge(filePath, memberResolved);
continue;
}
}
@@ -980,6 +1091,20 @@ export const processImportsFromExtracted = async (
continue;
}
// Swift: handle module imports
if (language === SupportedLanguages.Swift && swiftPackageConfig) {
const targetDir = swiftPackageConfig.targets.get(rawImportPath);
if (targetDir) {
const dirPrefix = targetDir + '/';
for (const fp of allFileList) {
if (fp.startsWith(dirPrefix) && fp.endsWith('.swift')) {
addImportEdge(filePath, fp);
}
}
}
continue;
}
// Standard resolution (has its own internal cache)
const resolvedPath = resolveImportPath(
filePath,
+109 -14
View File
@@ -5,9 +5,10 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js';
import { generateId } from '../../lib/utils.js';
import { SymbolTable } from './symbol-table.js';
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import { findSiblingChild, getLanguageFromFilename, yieldToEventLoop } from './utils.js';
import { detectFrameworkFromAST } from './framework-detection.js';
import { WorkerPool } from './workers/worker-pool.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage } from './workers/parse-worker.js';
import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage, ExtractedRoute } from './workers/parse-worker.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@@ -15,8 +16,41 @@ export interface WorkerExtractedData {
imports: ExtractedImport[];
calls: ExtractedCall[];
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
}
const DEFINITION_CAPTURE_KEYS = [
'definition.function',
'definition.class',
'definition.interface',
'definition.method',
'definition.struct',
'definition.enum',
'definition.namespace',
'definition.module',
'definition.trait',
'definition.impl',
'definition.type',
'definition.const',
'definition.static',
'definition.typedef',
'definition.macro',
'definition.union',
'definition.property',
'definition.record',
'definition.delegate',
'definition.annotation',
'definition.constructor',
'definition.template',
] as const;
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
for (const key of DEFINITION_CAPTURE_KEYS) {
if (captureMap[key]) return captureMap[key];
}
return null;
};
// ============================================================================
// EXPORT DETECTION - Language-specific visibility detection
// ============================================================================
@@ -30,7 +64,7 @@ export interface WorkerExtractedData {
* @param language - The programming language
* @returns true if the symbol is exported/public
*/
const isNodeExported = (node: any, name: string, language: string): boolean => {
export const isNodeExported = (node: any, name: string, language: string): boolean => {
let current = node;
switch (language) {
@@ -108,12 +142,56 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
}
return false;
// Kotlin: Default visibility is public (unlike Java)
// visibility_modifier is inside modifiers, a sibling of the name node within the declaration
case 'kotlin':
while (current) {
if (current.parent) {
const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier');
if (visMod) {
const text = visMod.text;
if (text === 'private' || text === 'internal' || text === 'protected') return false;
if (text === 'public') return true;
}
}
current = current.parent;
}
// No visibility modifier = public (Kotlin default)
return true;
// C/C++: No native export concept at language level
// Entry points will be detected via name patterns (main, etc.)
case 'c':
case 'cpp':
return false;
// Swift: Check for 'public' or 'open' access modifiers
case 'swift':
while (current) {
if (current.type === 'modifiers' || current.type === 'visibility_modifier') {
const text = current.text || '';
if (text.includes('public') || text.includes('open')) return true;
}
current = current.parent;
}
return false;
// PHP: Check for visibility modifier or top-level scope
case 'php':
while (current) {
if (current.type === 'class_declaration' ||
current.type === 'interface_declaration' ||
current.type === 'trait_declaration' ||
current.type === 'enum_declaration') {
return true;
}
if (current.type === 'visibility_modifier') {
return current.text === 'public';
}
current = current.parent;
}
return true; // Top-level functions are globally accessible
default:
return false;
}
@@ -138,7 +216,7 @@ const processParsingWithWorkers = async (
if (lang) parseableFiles.push({ path: file.path, content: file.content });
}
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [] };
if (parseableFiles.length === 0) return { imports: [], calls: [], heritage: [], routes: [] };
const total = files.length;
@@ -154,6 +232,7 @@ const processParsingWithWorkers = async (
const allImports: ExtractedImport[] = [];
const allCalls: ExtractedCall[] = [];
const allHeritage: ExtractedHeritage[] = [];
const allRoutes: ExtractedRoute[] = [];
for (const result of chunkResults) {
for (const node of result.nodes) {
graph.addNode({
@@ -174,11 +253,12 @@ const processParsingWithWorkers = async (
allImports.push(...result.imports);
allCalls.push(...result.calls);
allHeritage.push(...result.heritage);
allRoutes.push(...result.routes);
}
// Final progress
onFileProgress?.(total, total, 'done');
return { imports: allImports, calls: allCalls, heritage: allHeritage };
return { imports: allImports, calls: allCalls, heritage: allHeritage, routes: allRoutes };
};
// ============================================================================
@@ -209,7 +289,11 @@ const processParsingSequential = async (
// Skip very large files — they can crash tree-sitter or cause OOM
if (file.content.length > 512 * 1024) continue;
await loadLanguage(language, file.path);
try {
await loadLanguage(language, file.path);
} catch {
continue; // parser unavailable — already warned in pipeline
}
let tree;
try {
@@ -253,9 +337,9 @@ const processParsingSequential = async (
}
const nameNode = captureMap['name'];
if (!nameNode) return;
const nodeName = nameNode.text;
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && !captureMap['definition.constructor']) return;
const nodeName = nameNode ? nameNode.text : 'init';
let nodeLabel = 'CodeElement';
@@ -282,7 +366,14 @@ const processParsingSequential = async (
else if (captureMap['definition.constructor']) nodeLabel = 'Constructor';
else if (captureMap['definition.template']) nodeLabel = 'Template';
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
const startLine = definitionNodeForRange ? definitionNodeForRange.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}:${startLine}`);
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
const node: GraphNode = {
id: nodeId,
@@ -290,11 +381,15 @@ const processParsingSequential = async (
properties: {
name: nodeName,
filePath: file.path,
startLine: nameNode.startPosition.row,
endLine: nameNode.endPosition.row,
startLine: definitionNodeForRange ? definitionNodeForRange.startPosition.row : startLine,
endLine: definitionNodeForRange ? definitionNodeForRange.endPosition.row : startLine,
language: language,
isExported: isNodeExported(nameNode, nodeName, language),
}
isExported: isNodeExported(nameNode || definitionNodeForRange, nodeName, language),
...(frameworkHint ? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
},
};
graph.addNode(node);
+32 -2
View File
@@ -2,7 +2,7 @@ import { createKnowledgeGraph } from '../graph/graph.js';
import { processStructure } from './structure-processor.js';
import { processParsing } from './parsing-processor.js';
import { processImports, processImportsFromExtracted, createImportMap, buildImportResolutionContext } from './import-processor.js';
import { processCalls, processCallsFromExtracted } from './call-processor.js';
import { processCalls, processCallsFromExtracted, processRoutesFromExtracted } from './call-processor.js';
import { processHeritage, processHeritageFromExtracted } from './heritage-processor.js';
import { processCommunities } from './community-processor.js';
import { processProcesses } from './process-processor.js';
@@ -11,6 +11,7 @@ import { createASTCache } from './ast-cache.js';
import { PipelineProgress, PipelineResult } from '../../types/pipeline.js';
import { walkRepositoryPaths, readFileContents } from './filesystem-walker.js';
import { getLanguageFromFilename } from './utils.js';
import { isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { createWorkerPool, WorkerPool } from './workers/worker-pool.js';
const isDev = process.env.NODE_ENV === 'development';
@@ -88,9 +89,34 @@ export const runPipelineFromRepo = async (
// Group parseable files into byte-budget chunks so only ~20MB of source
// is in memory at a time. Each chunk is: read → parse → extract → free.
const parseableScanned = scannedFiles.filter(f => getLanguageFromFilename(f.path));
const parseableScanned = scannedFiles.filter(f => {
const lang = getLanguageFromFilename(f.path);
return lang && isLanguageAvailable(lang);
});
// Warn about files skipped due to unavailable parsers
const skippedByLang = new Map<string, number>();
for (const f of scannedFiles) {
const lang = getLanguageFromFilename(f.path);
if (lang && !isLanguageAvailable(lang)) {
skippedByLang.set(lang, (skippedByLang.get(lang) || 0) + 1);
}
}
for (const [lang, count] of skippedByLang) {
console.warn(`Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`);
}
const totalParseable = parseableScanned.length;
if (totalParseable === 0) {
onProgress({
phase: 'parsing',
percent: 82,
message: 'No parseable files found — skipping parsing phase',
stats: { filesProcessed: 0, totalFiles: 0, nodesCreated: graph.nodeCount },
});
}
// Build byte-budget chunks
const chunks: string[][] = [];
let currentChunk: string[] = [];
@@ -184,6 +210,10 @@ export const runPipelineFromRepo = async (
if (chunkWorkerData.heritage.length > 0) {
await processHeritageFromExtracted(graph, chunkWorkerData.heritage, symbolTable);
}
// Routes — resolve immediately (Laravel route→controller CALLS edges)
if (chunkWorkerData.routes && chunkWorkerData.routes.length > 0) {
await processRoutesFromExtracted(graph, chunkWorkerData.routes, symbolTable, importMap);
}
} else {
await processImports(graph, chunkFiles, astCache, importMap, undefined, repoPath, allPaths);
sequentialChunkPaths.push(chunkPaths);
@@ -285,7 +285,7 @@ const findEntryPoints = (
if (callees.length === 0) continue;
// Calculate entry point score using new scoring system
const { score, reasons } = calculateEntryPointScore(
const { score: baseScore, reasons } = calculateEntryPointScore(
node.properties.name,
node.properties.language || 'javascript',
node.properties.isExported ?? false,
@@ -294,6 +294,13 @@ const findEntryPoints = (
filePath // Pass filePath for framework detection
);
let score = baseScore;
const astFrameworkMultiplier = node.properties.astFrameworkMultiplier ?? 1.0;
if (astFrameworkMultiplier > 1.0) {
score *= astFrameworkMultiplier;
reasons.push(`framework-ast:${node.properties.astFrameworkReason || 'decorator'}`);
}
if (score > 0) {
entryPointCandidates.push({ id: node.id, score, reasons });
}
@@ -337,8 +344,7 @@ const traceFromEntryPoint = (
// BFS with path tracking
// Each queue item: [currentNodeId, pathSoFar]
const queue: [string, string[]][] = [[entryId, [entryId]]];
const visited = new Set<string>();
while (queue.length > 0 && traces.length < config.maxBranching * 3) {
const [currentId, path] = queue.shift()!;
@@ -396,6 +396,139 @@ export const PHP_QUERIES = `
[(name) (qualified_name)] @heritage.trait))) @heritage
`;
// Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin)
// Based on official tags.scm; functions use simple_identifier, classes use type_identifier
export const KOTLIN_QUERIES = `
; ── Interfaces ─────────────────────────────────────────────────────────────
; tree-sitter-kotlin (fwcd) has no interface_declaration node type.
; Interfaces are class_declaration nodes with an anonymous "interface" keyword child.
(class_declaration
"interface"
(type_identifier) @name) @definition.interface
; ── Classes (regular, data, sealed, enum) ────────────────────────────────
; All have the anonymous "class" keyword child. enum class has both
; "enum" and "class" children — the "class" child still matches.
(class_declaration
"class"
(type_identifier) @name) @definition.class
; ── Object declarations (Kotlin singletons) ──────────────────────────────
(object_declaration
(type_identifier) @name) @definition.class
; ── Companion objects (named only) ───────────────────────────────────────
(companion_object
(type_identifier) @name) @definition.class
; ── Functions (top-level, member, extension) ──────────────────────────────
(function_declaration
(simple_identifier) @name) @definition.function
; ── Properties ───────────────────────────────────────────────────────────
(property_declaration
(variable_declaration
(simple_identifier) @name)) @definition.property
; ── Enum entries ─────────────────────────────────────────────────────────
(enum_entry
(simple_identifier) @name) @definition.enum
; ── Type aliases ─────────────────────────────────────────────────────────
(type_alias
(type_identifier) @name) @definition.type
; ── Imports ──────────────────────────────────────────────────────────────
(import_header
(identifier) @import.source) @import
; ── Function calls (direct) ──────────────────────────────────────────────
(call_expression
(simple_identifier) @call.name) @call
; ── Method calls (via navigation: obj.method()) ──────────────────────────
(call_expression
(navigation_expression
(navigation_suffix
(simple_identifier) @call.name))) @call
; ── Constructor invocations ──────────────────────────────────────────────
(constructor_invocation
(user_type
(type_identifier) @call.name)) @call
; ── Infix function calls (e.g., a to b, x until y) ──────────────────────
(infix_expression
(simple_identifier) @call.name) @call
; ── Heritage: extends / implements via delegation_specifier ──────────────
; Interface implementation (bare user_type): class Foo : Bar
(class_declaration
(type_identifier) @heritage.class
(delegation_specifier
(user_type (type_identifier) @heritage.extends))) @heritage
; Class extension (constructor_invocation): class Foo : Bar()
(class_declaration
(type_identifier) @heritage.class
(delegation_specifier
(constructor_invocation
(user_type (type_identifier) @heritage.extends)))) @heritage
`;
// Swift queries - works with tree-sitter-swift
export const SWIFT_QUERIES = `
; Classes
(class_declaration "class" name: (type_identifier) @name) @definition.class
; Structs
(class_declaration "struct" name: (type_identifier) @name) @definition.struct
; Enums
(class_declaration "enum" name: (type_identifier) @name) @definition.enum
; Extensions (mapped to class — no dedicated label in schema)
(class_declaration "extension" name: (user_type (type_identifier) @name)) @definition.class
; Actors
(class_declaration "actor" name: (type_identifier) @name) @definition.class
; Protocols (mapped to interface)
(protocol_declaration name: (type_identifier) @name) @definition.interface
; Type aliases
(typealias_declaration name: (type_identifier) @name) @definition.type
; Functions (top-level and methods)
(function_declaration name: (simple_identifier) @name) @definition.function
; Protocol method declarations
(protocol_function_declaration name: (simple_identifier) @name) @definition.method
; Initializers
(init_declaration) @definition.constructor
; Properties (stored and computed)
(property_declaration (pattern (simple_identifier) @name)) @definition.property
; Imports
(import_declaration (identifier (simple_identifier) @import.source)) @import
; Calls - direct function calls
(call_expression (simple_identifier) @call.name) @call
; Calls - member/navigation calls (obj.method())
(call_expression (navigation_expression (navigation_suffix (simple_identifier) @call.name))) @call
; Heritage - class/struct/enum inheritance and protocol conformance
(class_declaration name: (type_identifier) @heritage.class
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
; Heritage - protocol inheritance
(protocol_declaration name: (type_identifier) @heritage.class
(inheritance_specifier inherits_from: (user_type (type_identifier) @heritage.extends))) @heritage
`;
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.TypeScript]: TYPESCRIPT_QUERIES,
[SupportedLanguages.JavaScript]: JAVASCRIPT_QUERIES,
@@ -407,5 +540,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.CSharp]: CSHARP_QUERIES,
[SupportedLanguages.Rust]: RUST_QUERIES,
[SupportedLanguages.PHP]: PHP_QUERIES,
[SupportedLanguages.Kotlin]: KOTLIN_QUERIES,
[SupportedLanguages.Swift]: SWIFT_QUERIES,
};
+20
View File
@@ -6,6 +6,23 @@ import { SupportedLanguages } from '../../config/supported-languages.js';
*/
export const yieldToEventLoop = (): Promise<void> => new Promise(resolve => setImmediate(resolve));
/**
* Find a child of `childType` within a sibling node of `siblingType`.
* Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling.
*/
export const findSiblingChild = (parent: any, siblingType: string, childType: string): any | null => {
for (let i = 0; i < parent.childCount; i++) {
const sibling = parent.child(i);
if (sibling?.type === siblingType) {
for (let j = 0; j < sibling.childCount; j++) {
const child = sibling.child(j);
if (child?.type === childType) return child;
}
}
}
return null;
};
/**
* Map file extension to SupportedLanguage enum
*/
@@ -31,12 +48,15 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages |
if (filename.endsWith('.go')) return SupportedLanguages.Go;
// Rust
if (filename.endsWith('.rs')) return SupportedLanguages.Rust;
// Kotlin
if (filename.endsWith('.kt') || filename.endsWith('.kts')) return SupportedLanguages.Kotlin;
// PHP (all common extensions)
if (filename.endsWith('.php') || filename.endsWith('.phtml') ||
filename.endsWith('.php3') || filename.endsWith('.php4') ||
filename.endsWith('.php5') || filename.endsWith('.php8')) {
return SupportedLanguages.PHP;
}
if (filename.endsWith('.swift')) return SupportedLanguages.Swift;
return null;
};
@@ -9,10 +9,18 @@ import CPP from 'tree-sitter-cpp';
import CSharp from 'tree-sitter-c-sharp';
import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import Kotlin from 'tree-sitter-kotlin';
import PHP from 'tree-sitter-php';
import { createRequire } from 'node:module';
import { SupportedLanguages } from '../../../config/supported-languages.js';
import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js';
import { getLanguageFromFilename } from '../utils.js';
// tree-sitter-swift is an optionalDependency — may not be installed
const _require = createRequire(import.meta.url);
let Swift: any = null;
try { Swift = _require('tree-sitter-swift'); } catch {}
import { findSiblingChild, getLanguageFromFilename } from '../utils.js';
import { detectFrameworkFromAST } from '../framework-detection.js';
import { generateId } from '../../../lib/utils.js';
// ============================================================================
@@ -29,6 +37,8 @@ interface ParsedNode {
endLine: number;
language: string;
isExported: boolean;
astFrameworkMultiplier?: number;
astFrameworkReason?: string;
description?: string;
};
}
@@ -70,6 +80,17 @@ export interface ExtractedHeritage {
kind: string;
}
export interface ExtractedRoute {
filePath: string;
httpMethod: string;
routePath: string | null;
controllerName: string | null;
methodName: string | null;
middleware: string[];
prefix: string | null;
lineNumber: number;
}
export interface ParseWorkerResult {
nodes: ParsedNode[];
relationships: ParsedRelationship[];
@@ -77,6 +98,7 @@ export interface ParseWorkerResult {
imports: ExtractedImport[];
calls: ExtractedCall[];
heritage: ExtractedHeritage[];
routes: ExtractedRoute[];
fileCount: number;
}
@@ -102,7 +124,9 @@ const languageMap: Record<string, any> = {
[SupportedLanguages.CSharp]: CSharp,
[SupportedLanguages.Go]: Go,
[SupportedLanguages.Rust]: Rust,
[SupportedLanguages.Kotlin]: Kotlin,
[SupportedLanguages.PHP]: PHP.php_only,
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
};
const setLanguage = (language: SupportedLanguages, filePath: string): void => {
@@ -184,6 +208,23 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
}
return false;
// Kotlin: Default visibility is public (unlike Java)
// visibility_modifier is inside modifiers, a sibling of the name node within the declaration
case 'kotlin':
while (current) {
if (current.parent) {
const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier');
if (visMod) {
const text = visMod.text;
if (text === 'private' || text === 'internal' || text === 'protected') return false;
if (text === 'public') return true;
}
}
current = current.parent;
}
// No visibility modifier = public (Kotlin default)
return true;
case 'c':
case 'cpp':
return false;
@@ -206,6 +247,16 @@ const isNodeExported = (node: any, name: string, language: string): boolean => {
// Top-level functions (no parent class) are globally accessible
return true;
case 'swift':
while (current) {
if (current.type === 'modifiers' || current.type === 'visibility_modifier') {
const text = current.text || '';
if (text.includes('public') || text.includes('open')) return true;
}
current = current.parent;
}
return false;
default:
return false;
}
@@ -221,7 +272,12 @@ const FUNCTION_NODE_TYPES = new Set([
'function_definition', 'async_function_declaration', 'async_arrow_function',
'method_declaration', 'constructor_declaration',
'local_function_statement', 'function_item', 'impl_item',
'anonymous_function_creation_expression', // PHP anonymous functions
// Kotlin
'lambda_literal',
// PHP
'anonymous_function',
// Swift initializers/deinitializers
'init_declaration', 'deinit_declaration',
]);
/** Walk up AST to find enclosing function, return its generateId or null for top-level */
@@ -232,6 +288,13 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
let funcName: string | null = null;
let label = 'Function';
if (current.type === 'init_declaration' || current.type === 'deinit_declaration') {
const funcName = current.type === 'init_declaration' ? 'init' : 'deinit';
const label = 'Constructor';
const startLine = current.startPosition?.row ?? 0;
return generateId(label, `${filePath}:${funcName}:${startLine}`);
}
if (['function_declaration', 'function_definition', 'async_function_declaration',
'generator_function_declaration', 'function_item'].includes(current.type)) {
const nameNode = current.childForFieldName?.('name') ||
@@ -265,7 +328,8 @@ const findEnclosingFunctionId = (node: any, filePath: string): string | null =>
}
if (funcName) {
return generateId(label, `${filePath}:${funcName}`);
const startLine = current.startPosition?.row ?? 0;
return generateId(label, `${filePath}:${funcName}:${startLine}`);
}
}
current = current.parent;
@@ -298,6 +362,22 @@ const BUILT_INS = new Set([
'open', 'read', 'write', 'close', 'append', 'extend', 'update',
'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr',
'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs',
// Kotlin stdlib (IMPORTANT: keep in sync with call-processor.ts BUILT_IN_NAMES)
'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error',
'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf',
'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless',
'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet',
'repeat', 'synchronized',
// Kotlin coroutine builders & scope functions
'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope',
'supervisorScope', 'delay',
// Kotlin Flow operators
'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch',
'buffer', 'conflate', 'distinctUntilChanged',
'flatMapLatest', 'flatMapMerge', 'combine',
'stateIn', 'shareIn', 'launchIn',
// Kotlin infix stdlib functions
'to', 'until', 'downTo', 'step',
// C/C++ standard library
'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf',
'scanf', 'fscanf', 'sscanf',
@@ -336,6 +416,37 @@ const BUILT_INS = new Set([
'preg_match', 'preg_match_all', 'preg_replace', 'preg_split',
'header', 'session_start', 'session_destroy', 'ob_start', 'ob_end_clean', 'ob_get_clean',
'dd', 'dump',
// Swift/iOS built-ins and standard library
'print', 'debugPrint', 'dump', 'fatalError', 'precondition', 'preconditionFailure',
'assert', 'assertionFailure', 'NSLog',
'abs', 'min', 'max', 'zip', 'stride', 'sequence', 'repeatElement',
'swap', 'withUnsafePointer', 'withUnsafeMutablePointer', 'withUnsafeBytes',
'autoreleasepool', 'unsafeBitCast', 'unsafeDowncast', 'numericCast',
'type', 'MemoryLayout',
// Swift collection/string methods (common noise)
'map', 'flatMap', 'compactMap', 'filter', 'reduce', 'forEach', 'contains',
'first', 'last', 'prefix', 'suffix', 'dropFirst', 'dropLast',
'sorted', 'reversed', 'enumerated', 'joined', 'split',
'append', 'insert', 'remove', 'removeAll', 'removeFirst', 'removeLast',
'isEmpty', 'count', 'index', 'startIndex', 'endIndex',
// UIKit/Foundation common methods (noise in call graph)
'addSubview', 'removeFromSuperview', 'layoutSubviews', 'setNeedsLayout',
'layoutIfNeeded', 'setNeedsDisplay', 'invalidateIntrinsicContentSize',
'addTarget', 'removeTarget', 'addGestureRecognizer',
'addConstraint', 'addConstraints', 'removeConstraint', 'removeConstraints',
'NSLocalizedString', 'Bundle',
'reloadData', 'reloadSections', 'reloadRows', 'performBatchUpdates',
'register', 'dequeueReusableCell', 'dequeueReusableSupplementaryView',
'beginUpdates', 'endUpdates', 'insertRows', 'deleteRows', 'insertSections', 'deleteSections',
'present', 'dismiss', 'pushViewController', 'popViewController', 'popToRootViewController',
'performSegue', 'prepare',
// GCD / async
'DispatchQueue', 'async', 'sync', 'asyncAfter',
'Task', 'withCheckedContinuation', 'withCheckedThrowingContinuation',
// Combine
'sink', 'store', 'assign', 'receive', 'subscribe',
// Notification / KVO
'addObserver', 'removeObserver', 'post', 'NotificationCenter',
]);
// ============================================================================
@@ -372,6 +483,51 @@ const getLabelFromCaptures = (captureMap: Record<string, any>): string | null =>
return 'CodeElement';
};
const DEFINITION_CAPTURE_KEYS = [
'definition.function',
'definition.class',
'definition.interface',
'definition.method',
'definition.struct',
'definition.enum',
'definition.namespace',
'definition.module',
'definition.trait',
'definition.impl',
'definition.type',
'definition.const',
'definition.static',
'definition.typedef',
'definition.macro',
'definition.union',
'definition.property',
'definition.record',
'definition.delegate',
'definition.annotation',
'definition.constructor',
'definition.template',
] as const;
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): any | null => {
for (const key of DEFINITION_CAPTURE_KEYS) {
if (captureMap[key]) return captureMap[key];
}
return null;
};
/**
* Append .* to a Kotlin import path if the AST has a wildcard_import sibling node.
* Pure function — returns a new string without mutating the input.
*/
const appendKotlinWildcard = (importPath: string, importNode: any): string => {
for (let i = 0; i < importNode.childCount; i++) {
if (importNode.child(i)?.type === 'wildcard_import') {
return importPath.endsWith('.*') ? importPath : `${importPath}.*`;
}
}
return importPath;
};
// ============================================================================
// Process a batch of files
// ============================================================================
@@ -384,6 +540,7 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
imports: [],
calls: [],
heritage: [],
routes: [],
fileCount: 0,
};
@@ -434,14 +591,22 @@ const processBatch = (files: ParseWorkerInput[], onProgress?: (filesProcessed: n
// Process regular files for this language
if (regularFiles.length > 0) {
setLanguage(language, regularFiles[0].path);
processFileGroup(regularFiles, language, queryString, result, onFileProcessed);
try {
setLanguage(language, regularFiles[0].path);
processFileGroup(regularFiles, language, queryString, result, onFileProcessed);
} catch {
// parser unavailable — skip this language group
}
}
// Process tsx files separately (different grammar)
if (tsxFiles.length > 0) {
setLanguage(language, tsxFiles[0].path);
processFileGroup(tsxFiles, language, queryString, result, onFileProcessed);
try {
setLanguage(language, tsxFiles[0].path);
processFileGroup(tsxFiles, language, queryString, result, onFileProcessed);
} catch {
// parser unavailable — skip this language group
}
}
}
@@ -551,6 +716,378 @@ function extractEloquentRelationDescription(methodNode: any): string | null {
return null;
}
// ============================================================================
// Laravel Route Extraction (procedural AST walk)
// ============================================================================
interface RouteGroupContext {
middleware: string[];
prefix: string | null;
controller: string | null;
}
const ROUTE_HTTP_METHODS = new Set([
'get', 'post', 'put', 'patch', 'delete', 'options', 'any', 'match',
]);
const ROUTE_RESOURCE_METHODS = new Set(['resource', 'apiResource']);
const RESOURCE_ACTIONS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'];
const API_RESOURCE_ACTIONS = ['index', 'store', 'show', 'update', 'destroy'];
/** Check if node is a scoped_call_expression with object 'Route' */
function isRouteStaticCall(node: any): boolean {
if (node.type !== 'scoped_call_expression') return false;
const obj = node.childForFieldName?.('object') ?? node.children?.[0];
return obj?.text === 'Route';
}
/** Get the method name from a scoped_call_expression or member_call_expression */
function getCallMethodName(node: any): string | null {
const nameNode = node.childForFieldName?.('name') ??
node.children?.find((c: any) => c.type === 'name');
return nameNode?.text ?? null;
}
/** Get the arguments node from a call expression */
function getArguments(node: any): any {
return node.children?.find((c: any) => c.type === 'arguments') ?? null;
}
/** Find the closure body inside arguments */
function findClosureBody(argsNode: any): any | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
if (child.type === 'argument') {
for (const inner of child.children ?? []) {
if (inner.type === 'anonymous_function' ||
inner.type === 'arrow_function') {
return inner.childForFieldName?.('body') ??
inner.children?.find((c: any) => c.type === 'compound_statement');
}
}
}
if (child.type === 'anonymous_function' ||
child.type === 'arrow_function') {
return child.childForFieldName?.('body') ??
child.children?.find((c: any) => c.type === 'compound_statement');
}
}
return null;
}
/** Extract first string argument from arguments node */
function extractFirstStringArg(argsNode: any): string | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (!target) continue;
if (target.type === 'string' || target.type === 'encapsed_string') {
return extractStringContent(target);
}
}
return null;
}
/** Extract middleware from arguments — handles string or array */
function extractMiddlewareArg(argsNode: any): string[] {
if (!argsNode) return [];
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (!target) continue;
if (target.type === 'string' || target.type === 'encapsed_string') {
const val = extractStringContent(target);
return val ? [val] : [];
}
if (target.type === 'array_creation_expression') {
const items: string[] = [];
for (const el of target.children ?? []) {
if (el.type === 'array_element_initializer') {
const str = el.children?.find((c: any) => c.type === 'string' || c.type === 'encapsed_string');
const val = str ? extractStringContent(str) : null;
if (val) items.push(val);
}
}
return items;
}
}
return [];
}
/** Extract Controller::class from arguments */
function extractClassArg(argsNode: any): string | null {
if (!argsNode) return null;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (target?.type === 'class_constant_access_expression') {
return target.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
return null;
}
/** Extract controller class name from arguments: [Controller::class, 'method'] or 'Controller@method' */
function extractControllerTarget(argsNode: any): { controller: string | null; method: string | null } {
if (!argsNode) return { controller: null, method: null };
const args: any[] = [];
for (const child of argsNode.children ?? []) {
if (child.type === 'argument') args.push(child.children?.[0]);
else if (child.type !== '(' && child.type !== ')' && child.type !== ',') args.push(child);
}
// Second arg is the handler
const handlerNode = args[1];
if (!handlerNode) return { controller: null, method: null };
// Array syntax: [UserController::class, 'index']
if (handlerNode.type === 'array_creation_expression') {
let controller: string | null = null;
let method: string | null = null;
const elements: any[] = [];
for (const el of handlerNode.children ?? []) {
if (el.type === 'array_element_initializer') elements.push(el);
}
if (elements[0]) {
const classAccess = findDescendant(elements[0], 'class_constant_access_expression');
if (classAccess) {
controller = classAccess.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
if (elements[1]) {
const str = findDescendant(elements[1], 'string');
method = str ? extractStringContent(str) : null;
}
return { controller, method };
}
// String syntax: 'UserController@index'
if (handlerNode.type === 'string' || handlerNode.type === 'encapsed_string') {
const text = extractStringContent(handlerNode);
if (text?.includes('@')) {
const [controller, method] = text.split('@');
return { controller, method };
}
}
// Class reference: UserController::class (invokable controller)
if (handlerNode.type === 'class_constant_access_expression') {
const controller = handlerNode.children?.find((c: any) => c.type === 'name')?.text ?? null;
return { controller, method: '__invoke' };
}
return { controller: null, method: null };
}
interface ChainedRouteCall {
isRouteFacade: boolean;
terminalMethod: string;
attributes: { method: string; argsNode: any }[];
terminalArgs: any;
node: any;
}
/**
* Unwrap a chained call like Route::middleware('auth')->prefix('api')->group(fn)
*/
function unwrapRouteChain(node: any): ChainedRouteCall | null {
if (node.type !== 'member_call_expression') return null;
const terminalMethod = getCallMethodName(node);
if (!terminalMethod) return null;
const terminalArgs = getArguments(node);
const attributes: { method: string; argsNode: any }[] = [];
let current = node.children?.[0];
while (current) {
if (current.type === 'member_call_expression') {
const method = getCallMethodName(current);
const args = getArguments(current);
if (method) attributes.unshift({ method, argsNode: args });
current = current.children?.[0];
} else if (current.type === 'scoped_call_expression') {
const obj = current.childForFieldName?.('object') ?? current.children?.[0];
if (obj?.text !== 'Route') return null;
const method = getCallMethodName(current);
const args = getArguments(current);
if (method) attributes.unshift({ method, argsNode: args });
return { isRouteFacade: true, terminalMethod, attributes, terminalArgs, node };
} else {
break;
}
}
return null;
}
/** Parse Route::group(['middleware' => ..., 'prefix' => ...], fn) array syntax */
function parseArrayGroupArgs(argsNode: any): RouteGroupContext {
const ctx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
if (!argsNode) return ctx;
for (const child of argsNode.children ?? []) {
const target = child.type === 'argument' ? child.children?.[0] : child;
if (target?.type === 'array_creation_expression') {
for (const el of target.children ?? []) {
if (el.type !== 'array_element_initializer') continue;
const children = el.children ?? [];
const arrowIdx = children.findIndex((c: any) => c.type === '=>');
if (arrowIdx === -1) continue;
const key = extractStringContent(children[arrowIdx - 1]);
const val = children[arrowIdx + 1];
if (key === 'middleware') {
if (val?.type === 'string') {
const s = extractStringContent(val);
if (s) ctx.middleware.push(s);
} else if (val?.type === 'array_creation_expression') {
for (const item of val.children ?? []) {
if (item.type === 'array_element_initializer') {
const str = item.children?.find((c: any) => c.type === 'string');
const s = str ? extractStringContent(str) : null;
if (s) ctx.middleware.push(s);
}
}
}
} else if (key === 'prefix') {
ctx.prefix = extractStringContent(val) ?? null;
} else if (key === 'controller') {
if (val?.type === 'class_constant_access_expression') {
ctx.controller = val.children?.find((c: any) => c.type === 'name')?.text ?? null;
}
}
}
}
}
return ctx;
}
function extractLaravelRoutes(tree: any, filePath: string): ExtractedRoute[] {
const routes: ExtractedRoute[] = [];
function resolveStack(stack: RouteGroupContext[]): { middleware: string[]; prefix: string | null; controller: string | null } {
const middleware: string[] = [];
let prefix: string | null = null;
let controller: string | null = null;
for (const ctx of stack) {
middleware.push(...ctx.middleware);
if (ctx.prefix) prefix = prefix ? `${prefix}/${ctx.prefix}`.replace(/\/+/g, '/') : ctx.prefix;
if (ctx.controller) controller = ctx.controller;
}
return { middleware, prefix, controller };
}
function emitRoute(
httpMethod: string,
argsNode: any,
lineNumber: number,
groupStack: RouteGroupContext[],
chainAttrs: { method: string; argsNode: any }[],
) {
const effective = resolveStack(groupStack);
for (const attr of chainAttrs) {
if (attr.method === 'middleware') effective.middleware.push(...extractMiddlewareArg(attr.argsNode));
if (attr.method === 'prefix') {
const p = extractFirstStringArg(attr.argsNode);
if (p) effective.prefix = effective.prefix ? `${effective.prefix}/${p}` : p;
}
if (attr.method === 'controller') {
const cls = extractClassArg(attr.argsNode);
if (cls) effective.controller = cls;
}
}
const routePath = extractFirstStringArg(argsNode);
if (ROUTE_RESOURCE_METHODS.has(httpMethod)) {
const target = extractControllerTarget(argsNode);
const actions = httpMethod === 'apiResource' ? API_RESOURCE_ACTIONS : RESOURCE_ACTIONS;
for (const action of actions) {
routes.push({
filePath, httpMethod, routePath,
controllerName: target.controller ?? effective.controller,
methodName: action,
middleware: [...effective.middleware],
prefix: effective.prefix,
lineNumber,
});
}
} else {
const target = extractControllerTarget(argsNode);
routes.push({
filePath, httpMethod, routePath,
controllerName: target.controller ?? effective.controller,
methodName: target.method,
middleware: [...effective.middleware],
prefix: effective.prefix,
lineNumber,
});
}
}
function walk(node: any, groupStack: RouteGroupContext[]) {
// Case 1: Simple Route::get(...), Route::post(...), etc.
if (isRouteStaticCall(node)) {
const method = getCallMethodName(node);
if (method && (ROUTE_HTTP_METHODS.has(method) || ROUTE_RESOURCE_METHODS.has(method))) {
emitRoute(method, getArguments(node), node.startPosition.row, groupStack, []);
return;
}
if (method === 'group') {
const argsNode = getArguments(node);
const groupCtx = parseArrayGroupArgs(argsNode);
const body = findClosureBody(argsNode);
if (body) {
groupStack.push(groupCtx);
walkChildren(body, groupStack);
groupStack.pop();
}
return;
}
}
// Case 2: Fluent chain — Route::middleware(...)->group(...) or Route::middleware(...)->get(...)
const chain = unwrapRouteChain(node);
if (chain) {
if (chain.terminalMethod === 'group') {
const groupCtx: RouteGroupContext = { middleware: [], prefix: null, controller: null };
for (const attr of chain.attributes) {
if (attr.method === 'middleware') groupCtx.middleware.push(...extractMiddlewareArg(attr.argsNode));
if (attr.method === 'prefix') groupCtx.prefix = extractFirstStringArg(attr.argsNode);
if (attr.method === 'controller') groupCtx.controller = extractClassArg(attr.argsNode);
}
const body = findClosureBody(chain.terminalArgs);
if (body) {
groupStack.push(groupCtx);
walkChildren(body, groupStack);
groupStack.pop();
}
return;
}
if (ROUTE_HTTP_METHODS.has(chain.terminalMethod) || ROUTE_RESOURCE_METHODS.has(chain.terminalMethod)) {
emitRoute(chain.terminalMethod, chain.terminalArgs, node.startPosition.row, groupStack, chain.attributes);
return;
}
}
// Default: recurse into children
walkChildren(node, groupStack);
}
function walkChildren(node: any, groupStack: RouteGroupContext[]) {
for (const child of node.children ?? []) {
walk(child, groupStack);
}
}
walk(tree.rootNode, []);
return routes;
}
const processFileGroup = (
files: ParseWorkerInput[],
language: SupportedLanguages,
@@ -595,7 +1132,9 @@ const processFileGroup = (
// Extract import paths before skipping
if (captureMap['import'] && captureMap['import.source']) {
const rawImportPath = captureMap['import.source'].text.replace(/['"<>]/g, '');
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import'])
: captureMap['import.source'].text.replace(/['"<>]/g, '');
result.imports.push({
filePath: file.path,
rawImportPath,
@@ -654,8 +1193,12 @@ const processFileGroup = (
if (!nodeLabel) continue;
const nameNode = captureMap['name'];
const nodeName = nameNode.text;
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
// Synthesize name for constructors without explicit @name capture (e.g. Swift init)
if (!nameNode && nodeLabel !== 'Constructor') continue;
const nodeName = nameNode ? nameNode.text : 'init';
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const startLine = definitionNode ? definitionNode.startPosition.row : (nameNode ? nameNode.startPosition.row : 0);
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}:${startLine}`);
let description: string | undefined;
if (language === SupportedLanguages.PHP) {
@@ -666,16 +1209,24 @@ const processFileGroup = (
}
}
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
result.nodes.push({
id: nodeId,
label: nodeLabel,
properties: {
name: nodeName,
filePath: file.path,
startLine: nameNode.startPosition.row,
endLine: nameNode.endPosition.row,
startLine: definitionNode ? definitionNode.startPosition.row : startLine,
endLine: definitionNode ? definitionNode.endPosition.row : startLine,
language: language,
isExported: isNodeExported(nameNode, nodeName, language),
isExported: isNodeExported(nameNode || definitionNode, nodeName, language),
...(frameworkHint ? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
...(description !== undefined ? { description } : {}),
},
});
@@ -698,6 +1249,12 @@ const processFileGroup = (
reason: '',
});
}
// Extract Laravel routes from route files via procedural AST walk
if (language === SupportedLanguages.PHP && (file.path.includes('/routes/') || file.path.startsWith('routes/')) && file.path.endsWith('.php')) {
const extractedRoutes = extractLaravelRoutes(tree, file.path);
result.routes.push(...extractedRoutes);
}
}
};
@@ -708,7 +1265,7 @@ const processFileGroup = (
/** Accumulated result across sub-batches */
let accumulated: ParseWorkerResult = {
nodes: [], relationships: [], symbols: [],
imports: [], calls: [], heritage: [], fileCount: 0,
imports: [], calls: [], heritage: [], routes: [], fileCount: 0,
};
let cumulativeProcessed = 0;
@@ -719,6 +1276,7 @@ const mergeResult = (target: ParseWorkerResult, src: ParseWorkerResult) => {
target.imports.push(...src.imports);
target.calls.push(...src.calls);
target.heritage.push(...src.heritage);
target.routes.push(...src.routes);
target.fileCount += src.fileCount;
};
@@ -740,7 +1298,7 @@ parentPort!.on('message', (msg: any) => {
if (msg && msg.type === 'flush') {
parentPort!.postMessage({ type: 'result', data: accumulated });
// Reset for potential reuse
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], fileCount: 0 };
accumulated = { nodes: [], relationships: [], symbols: [], imports: [], calls: [], heritage: [], routes: [], fileCount: 0 };
cumulativeProcessed = 0;
return;
}
+24 -8
View File
@@ -25,7 +25,7 @@ const FLUSH_EVERY = 500;
// CSV ESCAPE UTILITIES
// ============================================================================
const sanitizeUTF8 = (str: string): string => {
export const sanitizeUTF8 = (str: string): string => {
return str
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
@@ -34,14 +34,14 @@ const sanitizeUTF8 = (str: string): string => {
.replace(/[\uFFFE\uFFFF]/g, '');
};
const escapeCSVField = (value: string | number | undefined | null): string => {
export const escapeCSVField = (value: string | number | undefined | null): string => {
if (value === undefined || value === null) return '""';
let str = String(value);
str = sanitizeUTF8(str);
return `"${str.replace(/"/g, '""')}"`;
};
const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
export const escapeCSVNumber = (value: number | undefined | null, defaultValue: number = -1): string => {
if (value === undefined || value === null) return String(defaultValue);
return String(value);
};
@@ -50,7 +50,7 @@ const escapeCSVNumber = (value: number | undefined | null, defaultValue: number
// CONTENT EXTRACTION (lazy — reads from disk on demand)
// ============================================================================
const isBinaryContent = (content: string): boolean => {
export const isBinaryContent = (content: string): boolean => {
if (!content || content.length === 0) return false;
const sample = content.slice(0, 1000);
let nonPrintable = 0;
@@ -80,7 +80,15 @@ class FileContentCache {
async get(relativePath: string): Promise<string> {
if (!relativePath) return '';
const cached = this.cache.get(relativePath);
if (cached !== undefined) return cached;
if (cached !== undefined) {
// Move to end of accessOrder (LRU promotion)
const idx = this.accessOrder.indexOf(relativePath);
if (idx !== -1) {
this.accessOrder.splice(idx, 1);
this.accessOrder.push(relativePath);
}
return cached;
}
try {
const fullPath = path.join(this.repoPath, relativePath);
const content = await fs.readFile(fullPath, 'utf-8');
@@ -163,9 +171,17 @@ class BufferedCSVWriter {
const chunk = this.buffer.join('\n') + '\n';
this.buffer.length = 0;
return new Promise((resolve, reject) => {
this.ws.once('error', reject);
const ok = this.ws.write(chunk);
if (ok) resolve();
else this.ws.once('drain', resolve);
if (ok) {
this.ws.removeListener('error', reject);
resolve();
} else {
this.ws.once('drain', () => {
this.ws.removeListener('error', reject);
resolve();
});
}
});
}
@@ -264,7 +280,7 @@ export const streamAllCSVsToDisk = async (
break;
case 'Community': {
const keywords = (node.properties as any).keywords || [];
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/'/g, "''")}'`).join(',')}]`;
const keywordsStr = `[${keywords.map((k: string) => `'${k.replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/,/g, '\\,')}'`).join(',')}]`;
await communityWriter.addRow([
escapeCSVField(node.id),
escapeCSVField(node.properties.name || ''),
+8 -7
View File
@@ -674,24 +674,25 @@ export const getEmbeddingTableName = (): string => EMBEDDING_TABLE_NAME;
/**
* Load the FTS extension (required before using FTS functions).
* Safe to call multiple times — tracks loaded state.
* Safe to call multiple times — tracks loaded state via module-level ftsLoaded.
*/
let ftsLoaded = false;
export const loadFTSExtension = async (): Promise<void> => {
if (ftsLoaded) return;
if (!conn) {
throw new Error('KuzuDB not initialized. Call initKuzu first.');
}
if (ftsLoaded) return;
try {
await conn.query('INSTALL fts');
await conn.query('LOAD EXTENSION fts');
ftsLoaded = true;
} catch {
// Extension may already be loaded
ftsLoaded = true;
} catch (err: any) {
const msg = err?.message || '';
if (msg.includes('already loaded') || msg.includes('already installed') || msg.includes('already exists')) {
ftsLoaded = true;
} else {
console.error('GitNexus: FTS extension load failed:', msg);
}
}
ftsLoaded = true;
};
/**
+8
View File
@@ -242,6 +242,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM Function TO \`Const\`,
FROM Function TO \`Typedef\`,
FROM Function TO \`Union\`,
FROM Function TO \`Property\`,
FROM Class TO Method,
FROM Class TO Function,
FROM Class TO Class,
@@ -301,7 +302,11 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Struct\` TO \`Enum\`,
FROM \`Struct\` TO Function,
FROM \`Struct\` TO Method,
FROM \`Struct\` TO Interface,
FROM \`Enum\` TO \`Enum\`,
FROM \`Enum\` TO Community,
FROM \`Enum\` TO Class,
FROM \`Enum\` TO Interface,
FROM \`Macro\` TO Community,
FROM \`Macro\` TO Function,
FROM \`Macro\` TO Method,
@@ -318,6 +323,7 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Impl\` TO \`Impl\`,
FROM \`TypeAlias\` TO Community,
FROM \`TypeAlias\` TO \`Trait\`,
FROM \`TypeAlias\` TO Class,
FROM \`Const\` TO Community,
FROM \`Static\` TO Community,
FROM \`Property\` TO Community,
@@ -339,6 +345,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Constructor\` TO \`Impl\`,
FROM \`Constructor\` TO \`Namespace\`,
FROM \`Constructor\` TO \`Module\`,
FROM \`Constructor\` TO \`Property\`,
FROM \`Constructor\` TO \`Typedef\`,
FROM \`Template\` TO Community,
FROM \`Module\` TO Community,
FROM Function TO Process,
@@ -8,9 +8,16 @@ import CPP from 'tree-sitter-cpp';
import CSharp from 'tree-sitter-c-sharp';
import Go from 'tree-sitter-go';
import Rust from 'tree-sitter-rust';
import Kotlin from 'tree-sitter-kotlin';
import PHP from 'tree-sitter-php';
import { createRequire } from 'node:module';
import { SupportedLanguages } from '../../config/supported-languages.js';
// tree-sitter-swift is an optionalDependency — may not be installed
const _require = createRequire(import.meta.url);
let Swift: any = null;
try { Swift = _require('tree-sitter-swift'); } catch {}
let parser: Parser | null = null;
const languageMap: Record<string, any> = {
@@ -24,9 +31,14 @@ const languageMap: Record<string, any> = {
[SupportedLanguages.CSharp]: CSharp,
[SupportedLanguages.Go]: Go,
[SupportedLanguages.Rust]: Rust,
[SupportedLanguages.Kotlin]: Kotlin,
[SupportedLanguages.PHP]: PHP.php_only,
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
};
export const isLanguageAvailable = (language: SupportedLanguages): boolean =>
language in languageMap;
export const loadParser = async (): Promise<Parser> => {
if (parser) return parser;
parser = new Parser();
+3 -3
View File
@@ -12,7 +12,7 @@
import fs from 'fs/promises';
import path from 'path';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import {
initWikiDb,
@@ -712,8 +712,8 @@ export class WikiGenerator {
private getChangedFiles(fromCommit: string, toCommit: string): string[] {
try {
const output = execSync(
`git diff ${fromCommit}..${toCommit} --name-only`,
const output = execFileSync(
'git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'],
{ cwd: this.repoPath },
).toString().trim();
return output ? output.split('\n').filter(Boolean) : [];
@@ -0,0 +1,240 @@
import process from 'node:process';
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
import { JSONRPCMessageSchema, type JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
export type StdioFraming = 'content-length' | 'newline';
function deserializeMessage(raw: string): JSONRPCMessage {
return JSONRPCMessageSchema.parse(JSON.parse(raw));
}
function serializeNewlineMessage(message: JSONRPCMessage): string {
return `${JSON.stringify(message)}\n`;
}
function serializeContentLengthMessage(message: JSONRPCMessage): string {
const body = JSON.stringify(message);
return `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
}
function findHeaderEnd(buffer: Buffer): { index: number; separatorLength: number } | null {
const crlfEnd = buffer.indexOf('\r\n\r\n');
if (crlfEnd !== -1) {
return { index: crlfEnd, separatorLength: 4 };
}
const lfEnd = buffer.indexOf('\n\n');
if (lfEnd !== -1) {
return { index: lfEnd, separatorLength: 2 };
}
return null;
}
function looksLikeContentLength(buffer: Buffer): boolean {
if (buffer.length < 14) {
return false;
}
const probe = buffer.toString('utf8', 0, Math.min(buffer.length, 32));
return /^content-length\s*:/i.test(probe);
}
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10 MB — generous for JSON-RPC
export class CompatibleStdioServerTransport implements Transport {
private _readBuffer: Buffer | undefined;
private _started = false;
private _framing: StdioFraming | null = null;
onmessage?: (message: JSONRPCMessage) => void;
onerror?: (error: Error) => void;
onclose?: () => void;
constructor(
private readonly _stdin: NodeJS.ReadableStream = process.stdin,
private readonly _stdout: NodeJS.WritableStream = process.stdout,
) {}
private readonly _ondata = (chunk: Buffer) => {
this._readBuffer = this._readBuffer ? Buffer.concat([this._readBuffer, chunk]) : chunk;
if (this._readBuffer.length > MAX_BUFFER_SIZE) {
this.onerror?.(new Error(`Read buffer exceeded maximum size (${MAX_BUFFER_SIZE} bytes)`));
this.discardBufferedInput();
return;
}
this.processReadBuffer();
};
private readonly _onerror = (error: Error) => {
this.onerror?.(error);
};
async start() {
if (this._started) {
throw new Error('CompatibleStdioServerTransport already started!');
}
this._started = true;
this._stdin.on('data', this._ondata);
this._stdin.on('error', this._onerror);
}
private detectFraming(): StdioFraming | null {
if (!this._readBuffer || this._readBuffer.length === 0) {
return null;
}
const firstByte = this._readBuffer[0];
if (firstByte === 0x7b || firstByte === 0x5b) {
return 'newline';
}
if (looksLikeContentLength(this._readBuffer)) {
return 'content-length';
}
return null;
}
private discardBufferedInput() {
this._readBuffer = undefined;
this._framing = null;
}
private readContentLengthMessage(): JSONRPCMessage | null {
if (!this._readBuffer) {
return null;
}
const header = findHeaderEnd(this._readBuffer);
if (header === null) {
return null;
}
const headerText = this._readBuffer
.toString('utf8', 0, header.index)
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
const match = headerText.match(/(?:^|\n)content-length\s*:\s*(\d+)/i);
if (!match) {
this.discardBufferedInput();
throw new Error('Missing Content-Length header from MCP client');
}
const contentLength = Number.parseInt(match[1], 10);
if (!Number.isFinite(contentLength) || contentLength < 0) {
this.discardBufferedInput();
throw new Error('Invalid Content-Length header from MCP client');
}
if (contentLength > MAX_BUFFER_SIZE) {
this.discardBufferedInput();
throw new Error(`Content-Length ${contentLength} exceeds maximum allowed size (${MAX_BUFFER_SIZE} bytes)`);
}
const bodyStart = header.index + header.separatorLength;
const bodyEnd = bodyStart + contentLength;
if (this._readBuffer.length < bodyEnd) {
return null;
}
const body = this._readBuffer.toString('utf8', bodyStart, bodyEnd);
this._readBuffer = this._readBuffer.subarray(bodyEnd);
return deserializeMessage(body);
}
private readNewlineMessage(): JSONRPCMessage | null {
if (!this._readBuffer) {
return null;
}
while (true) {
const newlineIndex = this._readBuffer.indexOf('\n');
if (newlineIndex === -1) {
return null;
}
const line = this._readBuffer.toString('utf8', 0, newlineIndex).replace(/\r$/, '');
this._readBuffer = this._readBuffer.subarray(newlineIndex + 1);
if (line.trim().length === 0) {
continue;
}
return deserializeMessage(line);
}
}
private readMessage(): JSONRPCMessage | null {
if (!this._readBuffer || this._readBuffer.length === 0) {
return null;
}
if (this._framing === null) {
this._framing = this.detectFraming();
if (this._framing === null) {
return null;
}
}
return this._framing === 'content-length'
? this.readContentLengthMessage()
: this.readNewlineMessage();
}
private processReadBuffer() {
while (true) {
try {
const message = this.readMessage();
if (message === null) {
break;
}
this.onmessage?.(message);
} catch (error) {
this.onerror?.(error as Error);
break;
}
}
}
async close() {
this._stdin.off('data', this._ondata);
this._stdin.off('error', this._onerror);
const remainingDataListeners = this._stdin.listenerCount('data');
if (remainingDataListeners === 0) {
this._stdin.pause();
}
this._started = false;
this._readBuffer = undefined;
this.onclose?.();
}
send(message: JSONRPCMessage, _options?: TransportSendOptions) {
return new Promise<void>((resolve, reject) => {
if (!this._started) {
reject(new Error('Transport is closed'));
return;
}
const payload = this._framing === 'newline'
? serializeNewlineMessage(message)
: serializeContentLengthMessage(message);
const onError = (error: Error) => {
this._stdout.removeListener('error', onError);
reject(error);
};
this._stdout.on('error', onError);
if (this._stdout.write(payload)) {
this._stdout.removeListener('error', onError);
resolve();
} else {
this._stdout.once('drain', () => {
this._stdout.removeListener('error', onError);
resolve();
});
}
});
}
}
+85 -16
View File
@@ -42,6 +42,10 @@ const INITIAL_CONNS_PER_REPO = 2;
let idleTimer: ReturnType<typeof setInterval> | null = null;
/** Saved real stdout.write — used to silence KuzuDB native output without race conditions */
const realStdoutWrite = process.stdout.write.bind(process.stdout);
let stdoutSilenceCount = 0;
/**
* Start the idle cleanup timer (runs every 60s)
*/
@@ -50,7 +54,7 @@ function ensureIdleTimer(): void {
idleTimer = setInterval(() => {
const now = Date.now();
for (const [repoId, entry] of pool) {
if (now - entry.lastUsed > IDLE_TIMEOUT_MS) {
if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
closeOne(repoId);
}
}
@@ -69,7 +73,7 @@ function evictLRU(): void {
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, entry] of pool) {
if (entry.lastUsed < oldestTime) {
if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) {
oldestTime = entry.lastUsed;
oldestId = id;
}
@@ -86,9 +90,9 @@ function closeOne(repoId: string): void {
const entry = pool.get(repoId);
if (!entry) return;
for (const conn of entry.available) {
try { conn.close(); } catch {}
try { conn.close(); } catch (e) { console.error('GitNexus [pool:close-conn]:', e instanceof Error ? e.message : e); }
}
try { entry.db.close(); } catch {}
try { entry.db.close(); } catch (e) { console.error('GitNexus [pool:close-db]:', e instanceof Error ? e.message : e); }
pool.delete(repoId);
}
@@ -96,16 +100,33 @@ function closeOne(repoId: string): void {
* Create a new Connection from a repo's Database.
* Silences stdout to prevent native module output from corrupting MCP stdio.
*/
function silenceStdout(): void {
if (stdoutSilenceCount++ === 0) {
process.stdout.write = (() => true) as any;
}
}
function restoreStdout(): void {
if (--stdoutSilenceCount <= 0) {
stdoutSilenceCount = 0;
process.stdout.write = realStdoutWrite;
}
}
function createConnection(db: kuzu.Database): kuzu.Connection {
const origWrite = process.stdout.write;
process.stdout.write = (() => true) as any;
silenceStdout();
try {
return new kuzu.Connection(db);
} finally {
process.stdout.write = origWrite;
restoreStdout();
}
}
/** Query timeout in milliseconds */
const QUERY_TIMEOUT_MS = 30_000;
/** Waiter queue timeout in milliseconds */
const WAITER_TIMEOUT_MS = 15_000;
const LOCK_RETRY_ATTEMPTS = 3;
const LOCK_RETRY_DELAY_MS = 2000;
@@ -134,8 +155,7 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
// avoids lock conflicts when `gitnexus analyze` is writing.
let lastError: Error | null = null;
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
const origWrite = process.stdout.write;
process.stdout.write = (() => true) as any;
silenceStdout();
try {
const db = new kuzu.Database(
dbPath,
@@ -143,7 +163,7 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
false, // enableCompression (default)
true, // readOnly
);
process.stdout.write = origWrite;
restoreStdout();
// Pre-create a small pool of connections
const available: kuzu.Connection[] = [];
@@ -155,7 +175,7 @@ export const initKuzu = async (repoId: string, dbPath: string): Promise<void> =>
ensureIdleTimer();
return;
} catch (err: any) {
process.stdout.write = origWrite;
restoreStdout();
lastError = err instanceof Error ? err : new Error(String(err));
const isLockError = lastError.message.includes('Could not set lock')
|| lastError.message.includes('lock');
@@ -189,10 +209,18 @@ function checkout(entry: PoolEntry): Promise<kuzu.Connection> {
return Promise.resolve(createConnection(entry.db));
}
// At capacity — queue the caller. checkin() will resolve this when
// a connection is returned, handing it directly to the next waiter.
return new Promise<kuzu.Connection>(resolve => {
entry.waiters.push(resolve);
// At capacity — queue the caller with a timeout.
return new Promise<kuzu.Connection>((resolve, reject) => {
const waiter = (conn: kuzu.Connection) => {
clearTimeout(timer);
resolve(conn);
};
const timer = setTimeout(() => {
const idx = entry.waiters.indexOf(waiter);
if (idx !== -1) entry.waiters.splice(idx, 1);
reject(new Error(`Connection pool exhausted: timed out after ${WAITER_TIMEOUT_MS}ms waiting for a free connection`));
}, WAITER_TIMEOUT_MS);
entry.waiters.push(waiter);
});
}
@@ -216,6 +244,15 @@ function checkin(entry: PoolEntry, conn: kuzu.Connection): void {
* Execute a query on a specific repo's connection pool.
* Automatically checks out a connection, runs the query, and returns it.
*/
/** Race a promise against a timeout */
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout>;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
export const executeQuery = async (repoId: string, cypher: string): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
@@ -226,7 +263,39 @@ export const executeQuery = async (repoId: string, cypher: string): Promise<any[
const conn = await checkout(entry);
try {
const queryResult = await conn.query(cypher);
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} finally {
checkin(entry, conn);
}
};
/**
* Execute a parameterized query on a specific repo's connection pool.
* Uses prepare/execute pattern to prevent Cypher injection.
*/
export const executeParameterized = async (
repoId: string,
cypher: string,
params: Record<string, any>,
): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
throw new Error(`KuzuDB not initialized for repo "${repoId}". Call initKuzu first.`);
}
entry.lastUsed = Date.now();
const conn = await checkout(entry);
try {
const stmt = await withTimeout(conn.prepare(cypher), QUERY_TIMEOUT_MS, 'Prepare');
if (!stmt.isSuccess()) {
const errMsg = await stmt.getErrorMessage();
throw new Error(`Prepare failed: ${errMsg}`);
}
const queryResult = await withTimeout(conn.execute(stmt, params), QUERY_TIMEOUT_MS, 'Execute');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
+168 -138
View File
@@ -8,7 +8,7 @@
import fs from 'fs/promises';
import path from 'path';
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
import { initKuzu, executeQuery, executeParameterized, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
// git utilities available if needed
@@ -24,7 +24,7 @@ import {
* Quick test-file detection for filtering impact results.
* Matches common test file patterns across all supported languages.
*/
function isTestFilePath(filePath: string): boolean {
export function isTestFilePath(filePath: string): boolean {
const p = filePath.toLowerCase().replace(/\\/g, '/');
return (
p.includes('.test.') || p.includes('.spec.') ||
@@ -37,13 +37,30 @@ function isTestFilePath(filePath: string): boolean {
}
/** Valid KuzuDB node labels for safe Cypher query construction */
const VALID_NODE_LABELS = new Set([
export const VALID_NODE_LABELS = new Set([
'File', 'Folder', 'Function', 'Class', 'Interface', 'Method', 'CodeElement',
'Community', 'Process', 'Struct', 'Enum', 'Macro', 'Typedef', 'Union',
'Namespace', 'Trait', 'Impl', 'TypeAlias', 'Const', 'Static', 'Property',
'Record', 'Delegate', 'Annotation', 'Constructor', 'Template', 'Module',
]);
/** Valid relation types for impact analysis filtering */
export const VALID_RELATION_TYPES = new Set(['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']);
/** Regex to detect write operations in user-supplied Cypher queries */
export const CYPHER_WRITE_RE = /\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH)\b/i;
/** Check if a Cypher query contains write operations */
export function isWriteQuery(query: string): boolean {
return CYPHER_WRITE_RE.test(query);
}
/** Structured error logging for query failures — replaces empty catch blocks */
function logQueryError(context: string, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err);
console.error(`GitNexus [${context}]: ${msg}`);
}
export interface CodebaseContext {
projectName: string;
stats: {
@@ -387,46 +404,44 @@ export class LocalBackend {
continue;
}
const escaped = sym.nodeId.replace(/'/g, "''");
// Find processes this symbol participates in
let processRows: any[] = [];
try {
processRows = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
processRows = await executeParameterized(repo.id, `
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
`);
} catch { /* symbol might not be in any process */ }
`, { nodeId: sym.nodeId });
} catch (e) { logQueryError('query:process-lookup', e); }
// Get cluster membership + cohesion (cohesion used as internal ranking signal)
let cohesion = 0;
let module: string | undefined;
try {
const cohesionRows = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
const cohesionRows = await executeParameterized(repo.id, `
MATCH (n {id: $nodeId})-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
RETURN c.cohesion AS cohesion, c.heuristicLabel AS module
LIMIT 1
`);
`, { nodeId: sym.nodeId });
if (cohesionRows.length > 0) {
cohesion = (cohesionRows[0].cohesion ?? cohesionRows[0][0]) || 0;
module = cohesionRows[0].module ?? cohesionRows[0][1];
}
} catch { /* no cluster info */ }
} catch (e) { logQueryError('query:cluster-info', e); }
// Optionally fetch content
let content: string | undefined;
if (includeContent) {
try {
const contentRows = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})
const contentRows = await executeParameterized(repo.id, `
MATCH (n {id: $nodeId})
RETURN n.content AS content
`);
`, { nodeId: sym.nodeId });
if (contentRows.length > 0) {
content = contentRows[0].content ?? contentRows[0][0];
}
} catch { /* skip */ }
} catch (e) { logQueryError('query:content-fetch', e); }
}
const symbolEntry = {
id: sym.nodeId,
name: sym.name,
@@ -535,13 +550,12 @@ export class LocalBackend {
for (const bm25Result of bm25Results) {
const fullPath = bm25Result.filePath;
try {
const symbolQuery = `
MATCH (n)
WHERE n.filePath = '${fullPath.replace(/'/g, "''")}'
const symbols = await executeParameterized(repo.id, `
MATCH (n)
WHERE n.filePath = $filePath
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
LIMIT 3
`;
const symbols = await executeQuery(repo.id, symbolQuery);
`, { filePath: fullPath });
if (symbols.length > 0) {
for (const sym of symbols) {
@@ -619,12 +633,11 @@ export class LocalBackend {
if (!VALID_NODE_LABELS.has(label)) continue;
try {
const escapedId = nodeId.replace(/'/g, "''");
const nodeQuery = label === 'File'
? `MATCH (n:File {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath`
: `MATCH (n:\`${label}\` {id: '${escapedId}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
const nodeRows = await executeQuery(repo.id, nodeQuery);
? `MATCH (n:File {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath`
: `MATCH (n:\`${label}\` {id: $nodeId}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
const nodeRows = await executeParameterized(repo.id, nodeQuery, { nodeId });
if (nodeRows.length > 0) {
const nodeRow = nodeRows[0];
results.push({
@@ -659,6 +672,11 @@ export class LocalBackend {
return { error: 'KuzuDB not ready. Index may be corrupted.' };
}
// Block write operations (defense-in-depth — DB is already read-only)
if (CYPHER_WRITE_RE.test(params.query)) {
return { error: 'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.' };
}
try {
const result = await executeQuery(repo.id, params.query);
return result;
@@ -817,31 +835,32 @@ export class LocalBackend {
let symbols: any[];
if (uid) {
const escaped = uid.replace(/'/g, "''");
symbols = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})
symbols = await executeParameterized(repo.id, `
MATCH (n {id: $uid})
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
LIMIT 1
`);
`, { uid });
} else {
const escaped = name!.replace(/'/g, "''");
const isQualified = name!.includes('/') || name!.includes(':');
let whereClause: string;
let queryParams: Record<string, any>;
if (file_path) {
const fpEscaped = file_path.replace(/'/g, "''");
whereClause = `WHERE n.name = '${escaped}' AND n.filePath CONTAINS '${fpEscaped}'`;
whereClause = `WHERE n.name = $symName AND n.filePath CONTAINS $filePath`;
queryParams = { symName: name!, filePath: file_path };
} else if (isQualified) {
whereClause = `WHERE n.id = '${escaped}' OR n.name = '${escaped}'`;
whereClause = `WHERE n.id = $symName OR n.name = $symName`;
queryParams = { symName: name! };
} else {
whereClause = `WHERE n.name = '${escaped}'`;
whereClause = `WHERE n.name = $symName`;
queryParams = { symName: name! };
}
symbols = await executeQuery(repo.id, `
symbols = await executeParameterized(repo.id, `
MATCH (n) ${whereClause}
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine${include_content ? ', n.content AS content' : ''}
LIMIT 10
`);
`, queryParams);
}
if (symbols.length === 0) {
@@ -865,32 +884,32 @@ export class LocalBackend {
// Step 3: Build full context
const sym = symbols[0];
const symId = (sym.id || sym[0]).replace(/'/g, "''");
const symId = sym.id || sym[0];
// Categorized incoming refs
const incomingRows = await executeQuery(repo.id, `
MATCH (caller)-[r:CodeRelation]->(n {id: '${symId}'})
const incomingRows = await executeParameterized(repo.id, `
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
LIMIT 30
`);
`, { symId });
// Categorized outgoing refs
const outgoingRows = await executeQuery(repo.id, `
MATCH (n {id: '${symId}'})-[r:CodeRelation]->(target)
const outgoingRows = await executeParameterized(repo.id, `
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
LIMIT 30
`);
`, { symId });
// Process participation
let processRows: any[] = [];
try {
processRows = await executeQuery(repo.id, `
MATCH (n {id: '${symId}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
processRows = await executeParameterized(repo.id, `
MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.id AS pid, p.heuristicLabel AS label, r.step AS step, p.stepCount AS stepCount
`);
} catch { /* no process info */ }
`, { symId });
} catch (e) { logQueryError('context:process-participation', e); }
// Helper to categorize refs
const categorize = (rows: any[]) => {
@@ -944,33 +963,31 @@ export class LocalBackend {
}
if (type === 'cluster') {
const escaped = name.replace(/'/g, "''");
const clusterQuery = `
const clusters = await executeParameterized(repo.id, `
MATCH (c:Community)
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
`;
const clusters = await executeQuery(repo.id, clusterQuery);
`, { clusterName: name });
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
const rawClusters = clusters.map((c: any) => ({
id: c.id || c[0], label: c.label || c[1], heuristicLabel: c.heuristicLabel || c[2],
cohesion: c.cohesion || c[3], symbolCount: c.symbolCount || c[4],
}));
let totalSymbols = 0, weightedCohesion = 0;
for (const c of rawClusters) {
const s = c.symbolCount || 0;
totalSymbols += s;
weightedCohesion += (c.cohesion || 0) * s;
}
const members = await executeQuery(repo.id, `
const members = await executeParameterized(repo.id, `
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 30
`);
`, { clusterName: name });
return {
cluster: {
@@ -988,21 +1005,21 @@ export class LocalBackend {
}
if (type === 'process') {
const processes = await executeQuery(repo.id, `
const processes = await executeParameterized(repo.id, `
MATCH (p:Process)
WHERE p.label = '${name.replace(/'/g, "''")}' OR p.heuristicLabel = '${name.replace(/'/g, "''")}'
WHERE p.label = $processName OR p.heuristicLabel = $processName
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
LIMIT 1
`);
`, { processName: name });
if (processes.length === 0) return { error: `Process '${name}' not found` };
const proc = processes[0];
const procId = proc.id || proc[0];
const steps = await executeQuery(repo.id, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
const steps = await executeParameterized(repo.id, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
ORDER BY r.step
`);
`, { procId });
return {
process: {
@@ -1029,30 +1046,30 @@ export class LocalBackend {
await this.ensureInitialized(repo.id);
const scope = params.scope || 'unstaged';
const { execSync } = await import('child_process');
// Build git diff command based on scope
let diffCmd: string;
const { execFileSync } = await import('child_process');
// Build git diff args based on scope (using execFileSync to avoid shell injection)
let diffArgs: string[];
switch (scope) {
case 'staged':
diffCmd = 'git diff --staged --name-only';
diffArgs = ['diff', '--staged', '--name-only'];
break;
case 'all':
diffCmd = 'git diff HEAD --name-only';
diffArgs = ['diff', 'HEAD', '--name-only'];
break;
case 'compare':
if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' };
diffCmd = `git diff ${params.base_ref} --name-only`;
diffArgs = ['diff', params.base_ref, '--name-only'];
break;
case 'unstaged':
default:
diffCmd = 'git diff --name-only';
diffArgs = ['diff', '--name-only'];
break;
}
let changedFiles: string[];
try {
const output = execSync(diffCmd, { cwd: repo.repoPath, encoding: 'utf-8' });
const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
changedFiles = output.trim().split('\n').filter(f => f.length > 0);
} catch (err: any) {
return { error: `Git diff failed: ${err.message}` };
@@ -1069,13 +1086,13 @@ export class LocalBackend {
// Map changed files to indexed symbols
const changedSymbols: any[] = [];
for (const file of changedFiles) {
const escaped = file.replace(/\\/g, '/').replace(/'/g, "''");
const normalizedFile = file.replace(/\\/g, '/');
try {
const symbols = await executeQuery(repo.id, `
MATCH (n) WHERE n.filePath CONTAINS '${escaped}'
const symbols = await executeParameterized(repo.id, `
MATCH (n) WHERE n.filePath CONTAINS $filePath
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 20
`);
`, { filePath: normalizedFile });
for (const sym of symbols) {
changedSymbols.push({
id: sym.id || sym[0],
@@ -1085,18 +1102,17 @@ export class LocalBackend {
change_type: 'Modified',
});
}
} catch { /* skip */ }
} catch (e) { logQueryError('detect-changes:file-symbols', e); }
}
// Find affected processes
const affectedProcesses = new Map<string, any>();
for (const sym of changedSymbols) {
const escaped = (sym.id as string).replace(/'/g, "''");
try {
const procs = await executeQuery(repo.id, `
MATCH (n {id: '${escaped}'})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
const procs = await executeParameterized(repo.id, `
MATCH (n {id: $nodeId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
RETURN p.id AS pid, p.heuristicLabel AS label, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
`);
`, { nodeId: sym.id });
for (const proc of procs) {
const pid = proc.pid || proc[0];
if (!affectedProcesses.has(pid)) {
@@ -1113,9 +1129,9 @@ export class LocalBackend {
step: proc.step || proc[4],
});
}
} catch { /* skip */ }
} catch (e) { logQueryError('detect-changes:process-lookup', e); }
}
const processCount = affectedProcesses.size;
const risk = processCount === 0 ? 'low' : processCount <= 5 ? 'medium' : processCount <= 15 ? 'high' : 'critical';
@@ -1147,10 +1163,19 @@ export class LocalBackend {
const { new_name, file_path } = params;
const dry_run = params.dry_run ?? true;
if (!params.symbol_name && !params.symbol_uid) {
return { error: 'Either symbol_name or symbol_uid is required.' };
}
/** Guard: ensure a file path resolves within the repo root (prevents path traversal) */
const assertSafePath = (filePath: string): string => {
const full = path.resolve(repo.repoPath, filePath);
if (!full.startsWith(repo.repoPath + path.sep) && full !== repo.repoPath) {
throw new Error(`Path traversal blocked: ${filePath}`);
}
return full;
};
// Step 1: Find the target symbol (reuse context's lookup)
const lookupResult = await this.context(repo, {
@@ -1186,15 +1211,16 @@ export class LocalBackend {
// The definition itself
if (sym.filePath && sym.startLine) {
try {
const content = await fs.readFile(path.join(repo.repoPath, sym.filePath), 'utf-8');
const content = await fs.readFile(assertSafePath(sym.filePath), 'utf-8');
const lines = content.split('\n');
const lineIdx = sym.startLine - 1;
if (lineIdx >= 0 && lineIdx < lines.length && lines[lineIdx].includes(oldName)) {
addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(oldName, new_name).trim(), 'graph');
const defRegex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
addEdit(sym.filePath, sym.startLine, lines[lineIdx].trim(), lines[lineIdx].replace(defRegex, new_name).trim(), 'graph');
}
} catch { /* skip */ }
} catch (e) { logQueryError('rename:read-definition', e); }
}
// All incoming refs from graph (callers, importers, etc.)
const allIncoming = [
...(lookupResult.incoming.calls || []),
@@ -1208,7 +1234,7 @@ export class LocalBackend {
for (const ref of allIncoming) {
if (!ref.filePath) continue;
try {
const content = await fs.readFile(path.join(repo.repoPath, ref.filePath), 'utf-8');
const content = await fs.readFile(assertSafePath(ref.filePath), 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(oldName)) {
@@ -1217,18 +1243,24 @@ export class LocalBackend {
break; // one edit per file from graph refs
}
}
} catch { /* skip */ }
} catch (e) { logQueryError('rename:read-ref', e); }
}
// Step 3: Text search for refs the graph might have missed
let astSearchEdits = 0;
const graphFiles = new Set([sym.filePath, ...allIncoming.map(r => r.filePath)].filter(Boolean));
// Simple text search across the repo for the old name (in files not already covered by graph)
try {
const { execSync } = await import('child_process');
const rgCmd = `rg -l --type-add "code:*.{ts,tsx,js,jsx,py,go,rs,java}" -t code "\\b${oldName}\\b" .`;
const output = execSync(rgCmd, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
const { execFileSync } = await import('child_process');
const rgArgs = [
'-l',
'--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java,c,h,cpp,cc,cxx,hpp,hxx,hh,cs,php,swift}',
'-t', 'code',
`\\b${oldName}\\b`,
'.',
];
const output = execFileSync('rg', rgArgs, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
const files = output.trim().split('\n').filter(f => f.length > 0);
for (const file of files) {
@@ -1236,19 +1268,20 @@ export class LocalBackend {
if (graphFiles.has(normalizedFile)) continue; // already covered by graph
try {
const content = await fs.readFile(path.join(repo.repoPath, normalizedFile), 'utf-8');
const content = await fs.readFile(assertSafePath(normalizedFile), 'utf-8');
const lines = content.split('\n');
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
for (let i = 0; i < lines.length; i++) {
regex.lastIndex = 0;
if (regex.test(lines[i])) {
regex.lastIndex = 0;
addEdit(normalizedFile, i + 1, lines[i].trim(), lines[i].replace(regex, new_name).trim(), 'text_search');
astSearchEdits++;
regex.lastIndex = 0; // reset regex
}
}
} catch { /* skip */ }
} catch (e) { logQueryError('rename:text-search-read', e); }
}
} catch { /* rg not available or no additional matches */ }
} catch (e) { logQueryError('rename:ripgrep', e); }
// Step 4: Apply or preview
const allChanges = Array.from(changes.values());
@@ -1258,12 +1291,12 @@ export class LocalBackend {
// Apply edits to files
for (const change of allChanges) {
try {
const fullPath = path.join(repo.repoPath, change.file_path);
const fullPath = assertSafePath(change.file_path);
let content = await fs.readFile(fullPath, 'utf-8');
const regex = new RegExp(`\\b${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
content = content.replace(regex, new_name);
await fs.writeFile(fullPath, content, 'utf-8');
} catch { /* skip failed files */ }
} catch (e) { logQueryError('rename:apply-edit', e); }
}
}
@@ -1292,22 +1325,22 @@ export class LocalBackend {
const { target, direction } = params;
const maxDepth = params.maxDepth || 3;
const relationTypes = params.relationTypes && params.relationTypes.length > 0
? params.relationTypes
const rawRelTypes = params.relationTypes && params.relationTypes.length > 0
? params.relationTypes.filter(t => VALID_RELATION_TYPES.has(t))
: ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const relationTypes = rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const includeTests = params.includeTests ?? false;
const minConfidence = params.minConfidence ?? 0;
const relTypeFilter = relationTypes.map(t => `'${t}'`).join(', ');
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
const targetQuery = `
const targets = await executeParameterized(repo.id, `
MATCH (n)
WHERE n.name = '${target.replace(/'/g, "''")}'
WHERE n.name = $targetName
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 1
`;
const targets = await executeQuery(repo.id, targetQuery);
`, { targetName: target });
if (targets.length === 0) return { error: `Target '${target}' not found` };
const sym = targets[0];
@@ -1349,7 +1382,7 @@ export class LocalBackend {
});
}
}
} catch { /* query failed for this depth level */ }
} catch (e) { logQueryError('impact:depth-traversal', e); }
frontier = nextFrontier;
}
@@ -1511,13 +1544,11 @@ export class LocalBackend {
const repo = await this.resolveRepo(repoName);
await this.ensureInitialized(repo.id);
const escaped = name.replace(/'/g, "''");
const clusterQuery = `
const clusters = await executeParameterized(repo.id, `
MATCH (c:Community)
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
RETURN c.id AS id, c.label AS label, c.heuristicLabel AS heuristicLabel, c.cohesion AS cohesion, c.symbolCount AS symbolCount
`;
const clusters = await executeQuery(repo.id, clusterQuery);
`, { clusterName: name });
if (clusters.length === 0) return { error: `Cluster '${name}' not found` };
const rawClusters = clusters.map((c: any) => ({
@@ -1532,12 +1563,12 @@ export class LocalBackend {
weightedCohesion += (c.cohesion || 0) * s;
}
const members = await executeQuery(repo.id, `
const members = await executeParameterized(repo.id, `
MATCH (n)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE c.label = '${escaped}' OR c.heuristicLabel = '${escaped}'
WHERE c.label = $clusterName OR c.heuristicLabel = $clusterName
RETURN DISTINCT n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 30
`);
`, { clusterName: name });
return {
cluster: {
@@ -1562,22 +1593,21 @@ export class LocalBackend {
const repo = await this.resolveRepo(repoName);
await this.ensureInitialized(repo.id);
const escaped = name.replace(/'/g, "''");
const processes = await executeQuery(repo.id, `
const processes = await executeParameterized(repo.id, `
MATCH (p:Process)
WHERE p.label = '${escaped}' OR p.heuristicLabel = '${escaped}'
WHERE p.label = $processName OR p.heuristicLabel = $processName
RETURN p.id AS id, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount
LIMIT 1
`);
`, { processName: name });
if (processes.length === 0) return { error: `Process '${name}' not found` };
const proc = processes[0];
const procId = proc.id || proc[0];
const steps = await executeQuery(repo.id, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: '${procId}'})
const steps = await executeParameterized(repo.id, `
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p {id: $procId})
RETURN n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, r.step AS step
ORDER BY r.step
`);
`, { procId });
return {
process: {
+22 -13
View File
@@ -11,8 +11,9 @@
* Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
*/
import { createRequire } from 'module';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CompatibleStdioServerTransport } from './compatible-stdio-transport.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
@@ -80,10 +81,12 @@ function getNextStepHint(toolName: string, args: Record<string, any> | undefined
* Transport-agnostic — caller connects the desired transport.
*/
export function createMCPServer(backend: LocalBackend): Server {
const require = createRequire(import.meta.url);
const pkgVersion: string = require('../../package.json').version;
const server = new Server(
{
name: 'gitnexus',
version: '1.1.9',
version: pkgVersion,
},
{
capabilities: {
@@ -274,19 +277,25 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
const server = createMCPServer(backend);
// Connect to stdio transport
const transport = new StdioServerTransport();
const transport = new CompatibleStdioServerTransport();
await server.connect(transport);
// Handle graceful shutdown
process.on('SIGINT', async () => {
await backend.disconnect();
await server.close();
// Graceful shutdown helper
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
try { await backend.disconnect(); } catch {}
try { await server.close(); } catch {}
process.exit(0);
});
};
process.on('SIGTERM', async () => {
await backend.disconnect();
await server.close();
process.exit(0);
});
// Handle graceful shutdown
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Handle stdio errors — stdin close means the parent process is gone
process.stdin.on('end', shutdown);
process.stdin.on('error', () => shutdown());
process.stdout.on('error', () => shutdown());
}
+3 -3
View File
@@ -5,7 +5,7 @@
* Returns a hint for the LLM to call analyze if stale.
*/
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import path from 'path';
export interface StalenessInfo {
@@ -20,8 +20,8 @@ export interface StalenessInfo {
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
try {
// Get count of commits between lastCommit and HEAD
const result = execSync(
`git rev-list --count ${lastCommit}..HEAD`,
const result = execFileSync(
'git', ['rev-list', '--count', `${lastCommit}..HEAD`],
{ cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
+4 -1
View File
@@ -1,4 +1,5 @@
import { execSync } from 'child_process';
import path from 'path';
// Git utilities for repository detection, commit tracking, and diff analysis
@@ -24,9 +25,11 @@ export const getCurrentCommit = (repoPath: string): string => {
*/
export const getGitRoot = (fromPath: string): string | null => {
try {
return execSync('git rev-parse --show-toplevel', { cwd: fromPath })
const raw = execSync('git rev-parse --show-toplevel', { cwd: fromPath })
.toString()
.trim();
// On Windows, git returns /d/Projects/Foo — path.resolve normalizes to D:\Projects\Foo
return path.resolve(raw);
} catch {
return null;
}
+13 -4
View File
@@ -201,9 +201,13 @@ export const registerRepo = async (repoPath: string, meta: RepoMeta): Promise<vo
const { storagePath } = getStoragePaths(resolved);
const entries = await readRegistry();
const existing = entries.findIndex(
(e) => path.resolve(e.path) === resolved
);
const existing = entries.findIndex((e) => {
const a = path.resolve(e.path);
const b = resolved;
return process.platform === 'win32'
? a.toLowerCase() === b.toLowerCase()
: a === b;
});
const entry: RegistryEntry = {
name,
@@ -296,5 +300,10 @@ export const loadCLIConfig = async (): Promise<CLIConfig> => {
export const saveCLIConfig = async (config: CLIConfig): Promise<void> => {
const dir = getGlobalDir();
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(getGlobalConfigPath(), JSON.stringify(config, null, 2), 'utf-8');
const configPath = getGlobalConfigPath();
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
// Restrict file permissions on Unix (config may contain API keys)
if (process.platform !== 'win32') {
try { await fs.chmod(configPath, 0o600); } catch { /* best-effort */ }
}
};
+19
View File
@@ -0,0 +1,19 @@
import type { ValidationResult } from './validator';
export interface DbRecord {
id: string;
value: string;
timestamp: number;
}
export async function saveToDb(input: ValidationResult): Promise<DbRecord> {
return {
id: Math.random().toString(36),
value: input.value,
timestamp: Date.now(),
};
}
export async function findById(id: string): Promise<DbRecord | null> {
return null;
}
+15
View File
@@ -0,0 +1,15 @@
import type { DbRecord } from './db';
export function formatResponse(record: DbRecord): string {
return JSON.stringify({
success: true,
data: record,
});
}
export function formatError(message: string): string {
return JSON.stringify({
success: false,
error: message,
});
}
+15
View File
@@ -0,0 +1,15 @@
import { validateInput } from './validator';
import { saveToDb } from './db';
import { formatResponse } from './formatter';
export class RequestHandler {
async handleRequest(input: string): Promise<string> {
const validated = validateInput(input);
const saved = await saveToDb(validated);
return formatResponse(saved);
}
}
export function createHandler(): RequestHandler {
return new RequestHandler();
}
+3
View File
@@ -0,0 +1,3 @@
export { RequestHandler, createHandler } from './handler';
export { validateInput, sanitize } from './validator';
export { formatResponse, formatError } from './formatter';
+15
View File
@@ -0,0 +1,15 @@
export interface ValidationResult {
valid: boolean;
value: string;
}
export function validateInput(input: string): ValidationResult {
if (!input || input.trim().length === 0) {
return { valid: false, value: '' };
}
return { valid: true, value: input.trim() };
}
export function sanitize(input: string): string {
return input.replace(/[<>]/g, '');
}
+13
View File
@@ -0,0 +1,13 @@
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
static int internal_helper(void) {
return 0;
}
void print_message(const char* msg) {
printf("%s\n", msg);
}
+19
View File
@@ -0,0 +1,19 @@
#include <string>
class UserManager {
public:
void addUser(const std::string& name) {
users_.push_back(name);
}
int getCount() const {
return static_cast<int>(users_.size());
}
private:
std::vector<std::string> users_;
};
int helperFunction(int x) {
return x * 2;
}
+22
View File
@@ -0,0 +1,22 @@
using System;
namespace SampleApp
{
public class Calculator
{
public int Add(int a, int b)
{
return a + b;
}
private int Multiply(int a, int b)
{
return a * b;
}
}
internal class Helper
{
public void DoWork() { }
}
}
+21
View File
@@ -0,0 +1,21 @@
package main
import "fmt"
// ExportedFunction is a public function
func ExportedFunction(name string) string {
return fmt.Sprintf("Hello, %s", name)
}
// unexportedFunction is a private function
func unexportedFunction() int {
return 42
}
type UserService struct {
Name string
}
func (s *UserService) GetName() string {
return s.Name
}
+15
View File
@@ -0,0 +1,15 @@
public class UserService {
private String name;
public UserService(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
private void reset() {
this.name = "";
}
}
+32
View File
@@ -0,0 +1,32 @@
const path = require('path');
class EventEmitter {
constructor() {
this.listeners = {};
}
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
emit(event, ...args) {
const handlers = this.listeners[event] || [];
handlers.forEach(handler => handler(...args));
}
}
function createLogger(prefix) {
return {
log: (msg) => console.log(`[${prefix}] ${msg}`),
error: (msg) => console.error(`[${prefix}] ${msg}`),
};
}
const formatDate = (date) => {
return date.toISOString().split('T')[0];
};
module.exports = { EventEmitter, createLogger, formatDate };
+21
View File
@@ -0,0 +1,21 @@
<?php
function topLevelFunction(string $name): string {
return "Hello, " . $name;
}
class UserRepository {
private array $users = [];
public function addUser(string $name): void {
$this->users[] = $name;
}
private function validateName(string $name): bool {
return strlen($name) > 0;
}
public function getUsers(): array {
return $this->users;
}
}
+14
View File
@@ -0,0 +1,14 @@
def public_function(x: int, y: int) -> int:
"""A public function."""
return x + y
def _private_helper(data: str) -> str:
"""A private helper function."""
return data.strip()
class Calculator:
def add(self, a: int, b: int) -> int:
return a + b
def _reset(self) -> None:
pass
+17
View File
@@ -0,0 +1,17 @@
pub fn public_function(x: i32) -> i32 {
x + 1
}
fn private_function() -> &'static str {
"private"
}
pub struct Config {
pub name: String,
}
impl Config {
pub fn new(name: &str) -> Self {
Config { name: name.to_string() }
}
}
+19
View File
@@ -0,0 +1,19 @@
class UserManager {
var users: [String] = []
init() {
users = []
}
func addUser(_ name: String) {
users.append(name)
}
public func getCount() -> Int {
return users.count
}
}
func helperFunction() -> String {
return "swift helper"
}
+27
View File
@@ -0,0 +1,27 @@
export interface UserConfig {
name: string;
email: string;
active: boolean;
}
export function validateUser(config: UserConfig): boolean {
return config.name.length > 0 && config.email.includes('@');
}
export class UserService {
private users: UserConfig[] = [];
addUser(user: UserConfig): void {
if (validateUser(user)) {
this.users.push(user);
}
}
getUser(name: string): UserConfig | undefined {
return this.users.find(u => u.name === name);
}
}
function internalHelper(): string {
return 'helper';
}
+41
View File
@@ -0,0 +1,41 @@
import React, { useState } from 'react';
interface ButtonProps {
label: string;
onClick: () => void;
}
export class Counter extends React.Component<{}, { count: number }> {
state = { count: 0 };
increment() {
this.setState({ count: this.state.count + 1 });
}
render() {
return <button onClick={() => this.increment()}>{this.state.count}</button>;
}
}
export const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
return <button onClick={onClick}>{label}</button>;
};
export function useCounter(initial: number = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement };
}
const App = () => {
const { count, increment } = useCounter();
return (
<div>
<h1>Count: {count}</h1>
<Button label="+" onClick={increment} />
</div>
);
};
export default App;
+32
View File
@@ -0,0 +1,32 @@
/**
* Test helper: Temporary KuzuDB factory
*
* Creates a temp directory, initializes KuzuDB with schema, and
* optionally loads minimal test data. Returns a cleanup function.
*/
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
export interface TestDBHandle {
dbPath: string;
cleanup: () => Promise<void>;
}
/**
* Create a temporary directory for KuzuDB tests.
* Returns the path and a cleanup function.
*/
export async function createTempDir(prefix: string = 'gitnexus-test-'): Promise<TestDBHandle> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
return {
dbPath: tmpDir,
cleanup: async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
},
};
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Test helper: In-memory knowledge graph builder
*
* Provides a convenient API for constructing test graphs
* without touching the filesystem or KuzuDB.
*/
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { KnowledgeGraph, GraphNode, NodeLabel, RelationshipType } from '../../src/core/graph/types.js';
export interface TestNodeInput {
id: string;
label: NodeLabel;
name: string;
filePath: string;
startLine?: number;
endLine?: number;
isExported?: boolean;
extra?: Record<string, any>;
}
export interface TestRelInput {
sourceId: string;
targetId: string;
type: RelationshipType;
confidence?: number;
reason?: string;
step?: number;
}
/**
* Build a test graph from simple input arrays.
*/
export function buildTestGraph(
nodes: TestNodeInput[],
relationships: TestRelInput[] = [],
): KnowledgeGraph {
const graph = createKnowledgeGraph();
for (const n of nodes) {
graph.addNode({
id: n.id,
label: n.label,
properties: {
name: n.name,
filePath: n.filePath,
startLine: n.startLine,
endLine: n.endLine,
isExported: n.isExported,
...n.extra,
},
});
}
for (const r of relationships) {
graph.addRelationship({
id: `${r.sourceId}-${r.type}-${r.targetId}`,
sourceId: r.sourceId,
targetId: r.targetId,
type: r.type,
confidence: r.confidence ?? 1.0,
reason: r.reason ?? '',
step: r.step,
});
}
return graph;
}
/**
* Create a minimal graph with a few files, functions, and relationships.
* Useful as a baseline for integration tests.
*/
export function createMinimalTestGraph(): KnowledgeGraph {
return buildTestGraph(
[
{ id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
{ id: 'file:src/utils.ts', label: 'File', name: 'utils.ts', filePath: 'src/utils.ts' },
{ id: 'func:main', label: 'Function', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true },
{ id: 'func:helper', label: 'Function', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true },
{ id: 'class:App', label: 'Class', name: 'App', filePath: 'src/index.ts', startLine: 12, endLine: 30, isExported: true },
{ id: 'folder:src', label: 'Folder', name: 'src', filePath: 'src' },
],
[
{ sourceId: 'func:main', targetId: 'func:helper', type: 'CALLS' },
{ sourceId: 'func:main', targetId: 'class:App', type: 'CALLS' },
{ sourceId: 'file:src/index.ts', targetId: 'func:main', type: 'CONTAINS' },
{ sourceId: 'file:src/utils.ts', targetId: 'func:helper', type: 'CONTAINS' },
],
);
}
@@ -0,0 +1,178 @@
/**
* P1 Integration Tests: CSV Pipeline
*
* Tests: streamAllCSVsToDisk with real graph data.
* Covers hardening fixes: LRU cache (#24), BufferedCSVWriter flush
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
import { buildTestGraph } from '../helpers/test-graph.js';
import { streamAllCSVsToDisk } from '../../src/core/kuzu/csv-generator.js';
let tmpHandle: TestDBHandle;
let csvDir: string;
let repoDir: string;
beforeAll(async () => {
tmpHandle = await createTempDir('csv-pipeline-test-');
csvDir = path.join(tmpHandle.dbPath, 'csv');
repoDir = path.join(tmpHandle.dbPath, 'repo');
// Create a fake repo directory with source files
await fs.mkdir(path.join(repoDir, 'src'), { recursive: true });
await fs.writeFile(
path.join(repoDir, 'src', 'index.ts'),
'export function main() {\n console.log("hello");\n helper();\n}\n\nexport class App {\n run() {}\n}\n',
);
await fs.writeFile(
path.join(repoDir, 'src', 'utils.ts'),
'export function helper() {\n return 42;\n}\n',
);
});
afterAll(async () => {
try { await tmpHandle.cleanup(); } catch { /* best-effort */ }
});
describe('streamAllCSVsToDisk', () => {
it('generates CSV files for all node types in the graph', async () => {
const graph = buildTestGraph(
[
{ id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
{ id: 'file:src/utils.ts', label: 'File', name: 'utils.ts', filePath: 'src/utils.ts' },
{ id: 'func:main', label: 'Function', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 4, isExported: true },
{ id: 'func:helper', label: 'Function', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 3, isExported: true },
{ id: 'class:App', label: 'Class', name: 'App', filePath: 'src/index.ts', startLine: 6, endLine: 8, isExported: true },
{ id: 'folder:src', label: 'Folder', name: 'src', filePath: 'src' },
],
[
{ sourceId: 'func:main', targetId: 'func:helper', type: 'CALLS' },
{ sourceId: 'file:src/index.ts', targetId: 'func:main', type: 'CONTAINS' },
{ sourceId: 'file:src/utils.ts', targetId: 'func:helper', type: 'CONTAINS' },
],
);
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
// Check that CSV files were created
expect(result.nodeFiles.size).toBeGreaterThan(0);
expect(result.relRows).toBe(3);
// Verify File CSV
const fileCsv = result.nodeFiles.get('File');
expect(fileCsv).toBeDefined();
expect(fileCsv!.rows).toBe(2);
// Verify Function CSV
const funcCsv = result.nodeFiles.get('Function');
expect(funcCsv).toBeDefined();
expect(funcCsv!.rows).toBe(2);
// Verify Class CSV
const classCsv = result.nodeFiles.get('Class');
expect(classCsv).toBeDefined();
expect(classCsv!.rows).toBe(1);
// Verify Folder CSV
const folderCsv = result.nodeFiles.get('Folder');
expect(folderCsv).toBeDefined();
expect(folderCsv!.rows).toBe(1);
// Verify relations CSV exists
const relContent = await fs.readFile(result.relCsvPath, 'utf-8');
const relLines = relContent.trim().split('\n');
expect(relLines.length).toBe(4); // header + 3 relationships
});
it('CSV content is properly escaped', async () => {
const graph = buildTestGraph([
{
id: 'file:src/index.ts',
label: 'File',
name: 'index.ts',
filePath: 'src/index.ts',
},
]);
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
const fileCsv = result.nodeFiles.get('File');
expect(fileCsv).toBeDefined();
const content = await fs.readFile(fileCsv!.csvPath, 'utf-8');
// Content should be properly quoted
expect(content).toContain('"file:src/index.ts"');
expect(content).toContain('"index.ts"');
});
it('handles community nodes with keywords', async () => {
const graph = buildTestGraph([
{
id: 'comm:auth',
label: 'Community' as any,
name: 'Auth',
filePath: '',
extra: {
heuristicLabel: 'Authentication',
keywords: ['auth', 'login', 'pass,word'],
description: 'Auth module',
enrichedBy: 'heuristic',
cohesion: 0.85,
symbolCount: 5,
},
},
]);
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
const commCsv = result.nodeFiles.get('Community');
expect(commCsv).toBeDefined();
expect(commCsv!.rows).toBe(1);
const content = await fs.readFile(commCsv!.csvPath, 'utf-8');
// Keywords with commas should be escaped with \,
expect(content).toContain('pass\\,word');
});
it('handles process nodes', async () => {
const graph = buildTestGraph([
{
id: 'proc:flow',
label: 'Process' as any,
name: 'LoginFlow',
filePath: '',
extra: {
heuristicLabel: 'User Login',
processType: 'intra_community',
stepCount: 3,
communities: ['auth'],
entryPointId: 'func:login',
terminalId: 'func:validate',
},
},
]);
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
const procCsv = result.nodeFiles.get('Process');
expect(procCsv).toBeDefined();
expect(procCsv!.rows).toBe(1);
});
it('deduplicates File nodes', async () => {
const graph = buildTestGraph([
{ id: 'file:src/index.ts', label: 'File', name: 'index.ts', filePath: 'src/index.ts' },
// Duplicate (same id) — should not appear twice
]);
// Add the same node again manually
graph.addNode({
id: 'file:src/index.ts',
label: 'File',
properties: { name: 'index.ts', filePath: 'src/index.ts' },
});
const result = await streamAllCSVsToDisk(graph, repoDir, csvDir);
const fileCsv = result.nodeFiles.get('File');
expect(fileCsv).toBeDefined();
expect(fileCsv!.rows).toBe(1);
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { walkRepositoryPaths, readFileContents } from '../../src/core/ingestion/filesystem-walker.js';
describe('filesystem-walker', () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-test-'));
// Create test directory structure
await fs.mkdir(path.join(tmpDir, 'src'), { recursive: true });
await fs.mkdir(path.join(tmpDir, 'src', 'components'), { recursive: true });
await fs.mkdir(path.join(tmpDir, 'node_modules', 'lodash'), { recursive: true });
await fs.mkdir(path.join(tmpDir, '.git'), { recursive: true });
await fs.writeFile(path.join(tmpDir, 'src', 'index.ts'), 'export const main = () => {}');
await fs.writeFile(path.join(tmpDir, 'src', 'utils.ts'), 'export const helper = () => {}');
await fs.writeFile(path.join(tmpDir, 'src', 'components', 'Button.tsx'), 'export const Button = () => <div/>');
await fs.writeFile(path.join(tmpDir, 'node_modules', 'lodash', 'index.js'), 'module.exports = {}');
await fs.writeFile(path.join(tmpDir, '.git', 'HEAD'), 'ref: refs/heads/main');
await fs.writeFile(path.join(tmpDir, 'package.json'), '{}');
await fs.writeFile(path.join(tmpDir, 'src', 'image.png'), Buffer.from([0x89, 0x50, 0x4E, 0x47]));
});
afterAll(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch { /* best-effort */ }
});
describe('walkRepositoryPaths', () => {
it('discovers source files', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map(f => f.path.replace(/\\/g, '/'));
expect(paths.some(p => p.includes('src/index.ts'))).toBe(true);
expect(paths.some(p => p.includes('src/utils.ts'))).toBe(true);
});
it('discovers nested files', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map(f => f.path.replace(/\\/g, '/'));
expect(paths.some(p => p.includes('components/Button.tsx'))).toBe(true);
});
it('skips node_modules', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map(f => f.path.replace(/\\/g, '/'));
expect(paths.every(p => !p.includes('node_modules'))).toBe(true);
});
it('skips .git directory', async () => {
const files = await walkRepositoryPaths(tmpDir);
const paths = files.map(f => f.path.replace(/\\/g, '/'));
expect(paths.every(p => !p.includes('.git/'))).toBe(true);
});
it('returns file sizes', async () => {
const files = await walkRepositoryPaths(tmpDir);
for (const file of files) {
expect(typeof file.size).toBe('number');
expect(file.size).toBeGreaterThan(0);
}
});
it('calls progress callback', async () => {
const onProgress = vi.fn();
await walkRepositoryPaths(tmpDir, onProgress);
expect(onProgress).toHaveBeenCalled();
});
});
describe('readFileContents', () => {
it('reads file contents by relative paths', async () => {
const contents = await readFileContents(tmpDir, ['src/index.ts', 'src/utils.ts']);
expect(contents.get('src/index.ts')).toContain('main');
expect(contents.get('src/utils.ts')).toContain('helper');
});
it('handles empty path list', async () => {
const contents = await readFileContents(tmpDir, []);
expect(contents.size).toBe(0);
});
it('skips non-existent files gracefully', async () => {
const contents = await readFileContents(tmpDir, ['nonexistent.ts']);
expect(contents.size).toBe(0);
});
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* P0 Integration Tests: KuzuDB Connection Pool
*
* Tests: initKuzu, executeQuery, executeParameterized, closeKuzu lifecycle
* Covers hardening fixes: parameterized queries, query timeout,
* waiter queue timeout, idle eviction guards, stdout silencing race
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import kuzu from 'kuzu';
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
import {
initKuzu,
executeQuery,
executeParameterized,
closeKuzu,
isKuzuReady,
} from '../../src/mcp/core/kuzu-adapter.js';
import { NODE_SCHEMA_QUERIES, REL_SCHEMA_QUERIES } from '../../src/core/kuzu/schema.js';
let tmpHandle: TestDBHandle;
let dbPath: string;
const REPO_ID = 'test-repo';
/**
* Create a writable KuzuDB with schema and seed data.
* The pool opens it read-only, so we must create it separately.
*/
async function createTestDB(dbDir: string): Promise<void> {
const db = new kuzu.Database(dbDir);
const conn = new kuzu.Connection(db);
// Create schema
for (const q of NODE_SCHEMA_QUERIES) {
await conn.query(q);
}
for (const q of REL_SCHEMA_QUERIES) {
await conn.query(q);
}
// Insert test data
await conn.query(`CREATE (f:File {id: 'file:index.ts', name: 'index.ts', filePath: 'src/index.ts', content: ''})`);
await conn.query(`CREATE (fn:Function {id: 'func:main', name: 'main', filePath: 'src/index.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`);
await conn.query(`CREATE (fn2:Function {id: 'func:helper', name: 'helper', filePath: 'src/utils.ts', startLine: 1, endLine: 5, isExported: true, content: '', description: ''})`);
await conn.query(`
MATCH (a:Function), (b:Function)
WHERE a.id = 'func:main' AND b.id = 'func:helper'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)
`);
conn.close();
db.close();
}
beforeAll(async () => {
tmpHandle = await createTempDir('kuzu-pool-test-');
dbPath = path.join(tmpHandle.dbPath, 'kuzu');
// KuzuDB creates the directory itself — do NOT mkdir
await createTestDB(dbPath);
}, 30000);
afterAll(async () => {
// NOTE: We intentionally skip closeKuzu() here because KuzuDB native
// cleanup in forked workers can cause segfaults on process exit.
// The OS reclaims resources when the worker process terminates.
try { await tmpHandle.cleanup(); } catch { /* best-effort */ }
});
afterEach(async () => {
// Clean up specific repo IDs used in tests, not all
try { await closeKuzu(REPO_ID); } catch { /* best-effort */ }
try { await closeKuzu('repo1'); } catch { /* best-effort */ }
try { await closeKuzu('repo2'); } catch { /* best-effort */ }
});
// ─── Lifecycle: init → query → close ─────────────────────────────────
describe('pool lifecycle', () => {
it('initKuzu + executeQuery + closeKuzu', async () => {
await initKuzu(REPO_ID, dbPath);
expect(isKuzuReady(REPO_ID)).toBe(true);
const rows = await executeQuery(REPO_ID, 'MATCH (n:Function) RETURN n.name AS name');
expect(rows.length).toBeGreaterThanOrEqual(2);
const names = rows.map((r: any) => r.name);
expect(names).toContain('main');
expect(names).toContain('helper');
await closeKuzu(REPO_ID);
expect(isKuzuReady(REPO_ID)).toBe(false);
});
it('initKuzu reuses existing pool entry', async () => {
await initKuzu(REPO_ID, dbPath);
await initKuzu(REPO_ID, dbPath); // second call should be no-op
expect(isKuzuReady(REPO_ID)).toBe(true);
});
it('closeKuzu is idempotent', async () => {
await initKuzu(REPO_ID, dbPath);
await closeKuzu(REPO_ID);
await closeKuzu(REPO_ID); // second close should not throw
expect(isKuzuReady(REPO_ID)).toBe(false);
});
it('closeKuzu with no args closes all repos', async () => {
await initKuzu('repo1', dbPath);
await initKuzu('repo2', dbPath);
expect(isKuzuReady('repo1')).toBe(true);
expect(isKuzuReady('repo2')).toBe(true);
await closeKuzu();
expect(isKuzuReady('repo1')).toBe(false);
expect(isKuzuReady('repo2')).toBe(false);
});
});
// ─── Parameterized queries ───────────────────────────────────────────
describe('executeParameterized', () => {
it('works with parameterized query', async () => {
await initKuzu(REPO_ID, dbPath);
const rows = await executeParameterized(
REPO_ID,
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
{ name: 'main' },
);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('main');
});
it('injection attempt is harmless with parameterized query', async () => {
await initKuzu(REPO_ID, dbPath);
const rows = await executeParameterized(
REPO_ID,
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
{ name: "' OR 1=1 --" }, // SQL/Cypher injection attempt
);
// Should return 0 rows, not all rows
expect(rows).toHaveLength(0);
});
});
// ─── Error handling ──────────────────────────────────────────────────
describe('error handling', () => {
it('throws when querying uninitialized repo', async () => {
await expect(executeQuery('nonexistent-repo', 'MATCH (n) RETURN n'))
.rejects.toThrow(/not initialized/);
});
it('throws when db path does not exist', async () => {
await expect(initKuzu('bad-repo', '/nonexistent/path/kuzu'))
.rejects.toThrow();
});
it('read-only mode: write query throws', async () => {
await initKuzu(REPO_ID, dbPath);
await expect(executeQuery(REPO_ID, "CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})"))
.rejects.toThrow();
});
});
// ─── Relationship queries ────────────────────────────────────────────
describe('relationship queries', () => {
it('can query relationships', async () => {
await initKuzu(REPO_ID, dbPath);
const rows = await executeQuery(
REPO_ID,
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee`,
);
expect(rows.length).toBeGreaterThanOrEqual(1);
const row = rows.find((r: any) => r.caller === 'main');
expect(row).toBeDefined();
expect(row.callee).toBe('helper');
});
});
@@ -0,0 +1,254 @@
/**
* P0 Integration Tests: Local Backend
*
* Tests tool implementations via direct KuzuDB queries.
* The full LocalBackend.callTool() requires a global registry,
* so here we test the security-critical behaviors directly:
* - Write-operation blocking in cypher
* - Query execution via the pool
* - Parameterized queries preventing injection
* - Read-only enforcement
*
* Covers hardening fixes: #1 (parameterized queries), #2 (write blocking),
* #3 (path traversal), #4 (relation allowlist), #25 (regex lastIndex),
* #26 (rename first-occurrence-only)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import kuzu from 'kuzu';
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
import {
initKuzu,
executeQuery,
executeParameterized,
closeKuzu,
} from '../../src/mcp/core/kuzu-adapter.js';
import { NODE_SCHEMA_QUERIES, REL_SCHEMA_QUERIES } from '../../src/core/kuzu/schema.js';
import {
CYPHER_WRITE_RE,
VALID_RELATION_TYPES,
isWriteQuery,
} from '../../src/mcp/local/local-backend.js';
let tmpHandle: TestDBHandle;
let dbPath: string;
const REPO_ID = 'backend-test';
async function createTestDB(dbDir: string): Promise<void> {
const db = new kuzu.Database(dbDir);
const conn = new kuzu.Connection(db);
for (const q of NODE_SCHEMA_QUERIES) {
await conn.query(q);
}
for (const q of REL_SCHEMA_QUERIES) {
await conn.query(q);
}
// Insert test data: files, functions, classes, relationships
await conn.query(`CREATE (f:File {id: 'file:auth.ts', name: 'auth.ts', filePath: 'src/auth.ts', content: 'auth module'})`);
await conn.query(`CREATE (f:File {id: 'file:utils.ts', name: 'utils.ts', filePath: 'src/utils.ts', content: 'utils module'})`);
await conn.query(`CREATE (fn:Function {id: 'func:login', name: 'login', filePath: 'src/auth.ts', startLine: 1, endLine: 15, isExported: true, content: 'function login() {}', description: 'User login'})`);
await conn.query(`CREATE (fn:Function {id: 'func:validate', name: 'validate', filePath: 'src/auth.ts', startLine: 17, endLine: 25, isExported: true, content: 'function validate() {}', description: 'Validate input'})`);
await conn.query(`CREATE (fn:Function {id: 'func:hash', name: 'hash', filePath: 'src/utils.ts', startLine: 1, endLine: 8, isExported: true, content: 'function hash() {}', description: 'Hash utility'})`);
await conn.query(`CREATE (c:Class {id: 'class:AuthService', name: 'AuthService', filePath: 'src/auth.ts', startLine: 30, endLine: 60, isExported: true, content: 'class AuthService {}', description: 'Authentication service'})`);
await conn.query(`CREATE (c:Community {id: 'comm:auth', label: 'Auth', heuristicLabel: 'Authentication', keywords: ['auth', 'login'], description: 'Auth module', enrichedBy: 'heuristic', cohesion: 0.8, symbolCount: 3})`);
await conn.query(`CREATE (p:Process {id: 'proc:login-flow', label: 'LoginFlow', heuristicLabel: 'User Login', processType: 'intra_community', stepCount: 2, communities: ['auth'], entryPointId: 'func:login', terminalId: 'func:validate'})`);
// Relationships
await conn.query(`
MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:validate'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 1.0, reason: 'direct', step: 0}]->(b)
`);
await conn.query(`
MATCH (a:Function), (b:Function) WHERE a.id = 'func:login' AND b.id = 'func:hash'
CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.9, reason: 'import-resolved', step: 0}]->(b)
`);
await conn.query(`
MATCH (a:Function), (c:Community) WHERE a.id = 'func:login' AND c.id = 'comm:auth'
CREATE (a)-[:CodeRelation {type: 'MEMBER_OF', confidence: 1.0, reason: '', step: 0}]->(c)
`);
await conn.query(`
MATCH (a:Function), (p:Process) WHERE a.id = 'func:login' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 1}]->(p)
`);
await conn.query(`
MATCH (a:Function), (p:Process) WHERE a.id = 'func:validate' AND p.id = 'proc:login-flow'
CREATE (a)-[:CodeRelation {type: 'STEP_IN_PROCESS', confidence: 1.0, reason: '', step: 2}]->(p)
`);
conn.close();
db.close();
}
beforeAll(async () => {
tmpHandle = await createTempDir('backend-test-');
dbPath = path.join(tmpHandle.dbPath, 'kuzu');
// KuzuDB creates the directory itself — do NOT mkdir
await createTestDB(dbPath);
await initKuzu(REPO_ID, dbPath);
}, 30000);
afterAll(async () => {
// NOTE: We intentionally skip closeKuzu() here because KuzuDB native
// cleanup in forked workers can cause segfaults on process exit.
// The OS reclaims resources when the worker process terminates.
try { await tmpHandle.cleanup(); } catch { /* best-effort */ }
});
// ─── Cypher write blocking ───────────────────────────────────────────
describe('cypher write blocking', () => {
const allWriteKeywords = ['CREATE', 'DELETE', 'SET', 'MERGE', 'REMOVE', 'DROP', 'ALTER', 'COPY', 'DETACH'];
for (const keyword of allWriteKeywords) {
it(`blocks ${keyword} query`, () => {
const blocked = isWriteQuery(`MATCH (n) ${keyword} n.name = "x"`);
expect(blocked).toBe(true);
});
}
it('allows valid read queries through the pool', async () => {
const rows = await executeQuery(REPO_ID, 'MATCH (n:Function) RETURN n.name AS name ORDER BY n.name');
expect(rows.length).toBeGreaterThanOrEqual(3);
});
});
// ─── Parameterized queries ───────────────────────────────────────────
describe('parameterized queries', () => {
it('finds exact match with parameter', async () => {
const rows = await executeParameterized(
REPO_ID,
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name, n.filePath AS filePath',
{ name: 'login' },
);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('login');
expect(rows[0].filePath).toBe('src/auth.ts');
});
it('injection is harmless', async () => {
const rows = await executeParameterized(
REPO_ID,
'MATCH (n:Function) WHERE n.name = $name RETURN n.name AS name',
{ name: "login' OR '1'='1" },
);
expect(rows).toHaveLength(0);
});
});
// ─── Relation type filtering ─────────────────────────────────────────
describe('relation type filtering', () => {
it('only allows valid relation types in queries', () => {
const validTypes = ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
const invalidTypes = ['CONTAINS', 'STEP_IN_PROCESS', 'MEMBER_OF', 'DROP_TABLE'];
for (const t of validTypes) {
expect(VALID_RELATION_TYPES.has(t)).toBe(true);
}
for (const t of invalidTypes) {
expect(VALID_RELATION_TYPES.has(t)).toBe(false);
}
});
it('can query relationships with valid types', async () => {
const rows = await executeQuery(
REPO_ID,
`MATCH (a:Function)-[r:CodeRelation {type: 'CALLS'}]->(b:Function) RETURN a.name AS caller, b.name AS callee ORDER BY b.name`,
);
expect(rows.length).toBeGreaterThanOrEqual(2);
});
});
// ─── Process queries ─────────────────────────────────────────────────
describe('process queries', () => {
it('can find processes', async () => {
const rows = await executeQuery(REPO_ID, 'MATCH (p:Process) RETURN p.heuristicLabel AS label, p.stepCount AS steps');
expect(rows.length).toBeGreaterThanOrEqual(1);
expect(rows[0].label).toBe('User Login');
});
it('can trace process steps', async () => {
const rows = await executeQuery(
REPO_ID,
`MATCH (s)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
WHERE p.id = 'proc:login-flow'
RETURN s.name AS symbol, r.step AS step
ORDER BY r.step`,
);
expect(rows).toHaveLength(2);
expect(rows[0].symbol).toBe('login');
expect(rows[0].step).toBe(1);
expect(rows[1].symbol).toBe('validate');
expect(rows[1].step).toBe(2);
});
});
// ─── Community queries ───────────────────────────────────────────────
describe('community queries', () => {
it('can find communities', async () => {
const rows = await executeQuery(REPO_ID, 'MATCH (c:Community) RETURN c.heuristicLabel AS label');
expect(rows.length).toBeGreaterThanOrEqual(1);
expect(rows[0].label).toBe('Authentication');
});
it('can find community members', async () => {
const rows = await executeQuery(
REPO_ID,
`MATCH (f)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community)
WHERE c.heuristicLabel = 'Authentication'
RETURN f.name AS name`,
);
expect(rows.length).toBeGreaterThanOrEqual(1);
expect(rows[0].name).toBe('login');
});
});
// ─── Read-only enforcement ───────────────────────────────────────────
describe('read-only database', () => {
it('rejects write operations at DB level', async () => {
await expect(
executeQuery(REPO_ID, `CREATE (n:Function {id: 'new', name: 'new', filePath: '', startLine: 0, endLine: 0, isExported: false, content: '', description: ''})`)
).rejects.toThrow();
});
});
// ─── Regex lastIndex hardening (#25) ─────────────────────────────────
describe('regex lastIndex (hardening #25)', () => {
it('CYPHER_WRITE_RE is non-global (no sticky lastIndex)', () => {
expect(CYPHER_WRITE_RE.global).toBe(false);
expect(CYPHER_WRITE_RE.sticky).toBe(false);
});
it('works correctly across multiple consecutive calls', () => {
// If the regex were global, lastIndex could cause false results
const results = [
isWriteQuery('CREATE (n)'), // true
isWriteQuery('MATCH (n) RETURN n'), // false
isWriteQuery('DELETE n'), // true
isWriteQuery('MATCH (n) RETURN n'), // false
isWriteQuery('SET n.x = 1'), // true
];
expect(results).toEqual([true, false, true, false, true]);
});
});
// ─── Content queries (include_content equivalent) ────────────────────
describe('content queries', () => {
it('can retrieve symbol content', async () => {
const rows = await executeQuery(
REPO_ID,
`MATCH (n:Function) WHERE n.name = 'login' RETURN n.content AS content`,
);
expect(rows).toHaveLength(1);
expect(rows[0].content).toContain('function login');
});
});
+211
View File
@@ -0,0 +1,211 @@
/**
* P1 Integration Tests: Tree-sitter Parsing
*
* Tests parsing of sample files via tree-sitter.
* Covers hardening fixes: Swift init constructor (#18),
* PHP export detection (#20), symbol ID with startLine (#19),
* definition node range (#22).
*/
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { isNodeExported } from '../../src/core/ingestion/parsing-processor.js';
const FIXTURES_DIR = path.join(process.cwd(), 'test', 'fixtures', 'sample-code');
// We test isNodeExported directly since it's a pure function
// that only needs a mock AST node, name, and language string.
/**
* Minimal mock of a tree-sitter AST node.
*/
function mockNode(type: string, text: string = '', parent?: any): any {
return {
type,
text,
parent: parent || null,
childCount: 0,
child: () => null,
};
}
// ─── isNodeExported per-language ─────────────────────────────────────
describe('isNodeExported', () => {
// TypeScript/JavaScript
describe('typescript', () => {
it('returns true when ancestor is export_statement', () => {
const exportStmt = mockNode('export_statement', 'export function foo() {}');
const fnDecl = mockNode('function_declaration', 'function foo() {}', exportStmt);
const nameNode = mockNode('identifier', 'foo', fnDecl);
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(true);
});
it('returns false for non-exported function', () => {
const fnDecl = mockNode('function_declaration', 'function foo() {}');
const nameNode = mockNode('identifier', 'foo', fnDecl);
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(false);
});
it('returns true when text starts with "export "', () => {
const parent = mockNode('lexical_declaration', 'export const foo = 1');
const nameNode = mockNode('identifier', 'foo', parent);
expect(isNodeExported(nameNode, 'foo', 'typescript')).toBe(true);
});
});
// Python
describe('python', () => {
it('public function (no underscore prefix)', () => {
const node = mockNode('identifier', 'public_function');
expect(isNodeExported(node, 'public_function', 'python')).toBe(true);
});
it('private function (underscore prefix)', () => {
const node = mockNode('identifier', '_private_helper');
expect(isNodeExported(node, '_private_helper', 'python')).toBe(false);
});
it('dunder method is private', () => {
const node = mockNode('identifier', '__init__');
expect(isNodeExported(node, '__init__', 'python')).toBe(false);
});
});
// Go
describe('go', () => {
it('uppercase first letter is exported', () => {
const node = mockNode('identifier', 'ExportedFunction');
expect(isNodeExported(node, 'ExportedFunction', 'go')).toBe(true);
});
it('lowercase first letter is unexported', () => {
const node = mockNode('identifier', 'unexportedFunction');
expect(isNodeExported(node, 'unexportedFunction', 'go')).toBe(false);
});
it('empty name is not exported', () => {
const node = mockNode('identifier', '');
expect(isNodeExported(node, '', 'go')).toBe(false);
});
});
// Rust
describe('rust', () => {
it('pub function is exported', () => {
const visMod = mockNode('visibility_modifier', 'pub');
const fnDecl = mockNode('function_item', 'pub fn foo() {}', visMod);
// For rust, isNodeExported walks up parents checking for visibility_modifier
// The visMod is a parent of the nameNode
const nameNode = mockNode('identifier', 'foo', visMod);
expect(isNodeExported(nameNode, 'foo', 'rust')).toBe(true);
});
it('non-pub function is not exported', () => {
const fnDecl = mockNode('function_item', 'fn foo() {}');
const nameNode = mockNode('identifier', 'foo', fnDecl);
expect(isNodeExported(nameNode, 'foo', 'rust')).toBe(false);
});
});
// PHP (hardening fix #20)
describe('php', () => {
it('top-level function is exported (globally accessible)', () => {
// PHP: top-level functions fall through all checks and return true
const program = mockNode('program', '<?php function topLevel() {}');
const fnDecl = mockNode('function_definition', 'function topLevel() {}', program);
const nameNode = mockNode('name', 'topLevel', fnDecl);
expect(isNodeExported(nameNode, 'topLevel', 'php')).toBe(true);
});
it('class declaration is exported', () => {
const classDecl = mockNode('class_declaration', 'class Foo {}');
const nameNode = mockNode('name', 'Foo', classDecl);
expect(isNodeExported(nameNode, 'Foo', 'php')).toBe(true);
});
it('public method has visibility_modifier = public', () => {
const visMod = mockNode('visibility_modifier', 'public');
const nameNode = mockNode('name', 'addUser', visMod);
expect(isNodeExported(nameNode, 'addUser', 'php')).toBe(true);
});
it('private method has visibility_modifier = private', () => {
const visMod = mockNode('visibility_modifier', 'private');
const nameNode = mockNode('name', 'validate', visMod);
expect(isNodeExported(nameNode, 'validate', 'php')).toBe(false);
});
});
// Swift
describe('swift', () => {
it('public function is exported', () => {
const visMod = mockNode('modifiers', 'public');
const nameNode = mockNode('identifier', 'getCount', visMod);
expect(isNodeExported(nameNode, 'getCount', 'swift')).toBe(true);
});
it('open function is exported', () => {
const visMod = mockNode('modifiers', 'open');
const nameNode = mockNode('identifier', 'doStuff', visMod);
expect(isNodeExported(nameNode, 'doStuff', 'swift')).toBe(true);
});
it('non-public function is not exported', () => {
const fnDecl = mockNode('function_declaration', 'func helper() {}');
const nameNode = mockNode('identifier', 'helper', fnDecl);
expect(isNodeExported(nameNode, 'helper', 'swift')).toBe(false);
});
});
// C/C++
describe('c/cpp', () => {
it('C functions are never exported', () => {
const node = mockNode('identifier', 'add');
expect(isNodeExported(node, 'add', 'c')).toBe(false);
});
it('C++ functions are never exported', () => {
const node = mockNode('identifier', 'helperFunction');
expect(isNodeExported(node, 'helperFunction', 'cpp')).toBe(false);
});
});
// C#
describe('csharp', () => {
it('public modifier means exported', () => {
const modifier = mockNode('modifier', 'public');
const nameNode = mockNode('identifier', 'Add', modifier);
expect(isNodeExported(nameNode, 'Add', 'csharp')).toBe(true);
});
it('no public modifier means not exported', () => {
const classDecl = mockNode('class_declaration', 'class Helper {}');
const nameNode = mockNode('identifier', 'Helper', classDecl);
expect(isNodeExported(nameNode, 'Helper', 'csharp')).toBe(false);
});
});
// Unknown language
describe('unknown language', () => {
it('returns false for unknown language', () => {
const node = mockNode('identifier', 'foo');
expect(isNodeExported(node, 'foo', 'unknown')).toBe(false);
});
});
});
// ─── Fixture files exist ─────────────────────────────────────────────
describe('fixture files', () => {
const fixtures = ['simple.ts', 'simple.py', 'simple.go', 'simple.swift',
'simple.php', 'simple.rs', 'simple.java', 'simple.c', 'simple.cpp', 'simple.cs'];
for (const fixture of fixtures) {
it(`${fixture} exists and is non-empty`, async () => {
const content = await fs.readFile(path.join(FIXTURES_DIR, fixture), 'utf-8');
expect(content.length).toBeGreaterThan(0);
});
}
});
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineProgress } from '../../src/types/pipeline.js';
const MINI_REPO = path.resolve(__dirname, '..', 'fixtures', 'mini-repo');
describe('pipeline end-to-end', () => {
it('indexes a mini repo and produces a valid graph', async () => {
const progressCalls: PipelineProgress[] = [];
const onProgress = (p: PipelineProgress) => progressCalls.push(p);
const result = await runPipelineFromRepo(MINI_REPO, onProgress);
// --- Graph should have nodes ---
expect(result.graph.nodeCount).toBeGreaterThan(0);
expect(result.graph.relationshipCount).toBeGreaterThan(0);
// --- Should find the 5 TypeScript files ---
expect(result.totalFileCount).toBe(5);
// --- Verify File nodes exist for each source file ---
const fileNodes: string[] = [];
result.graph.forEachNode(n => {
if (n.label === 'File') fileNodes.push(n.properties.filePath || n.properties.name);
});
expect(fileNodes).toContain('src/handler.ts');
expect(fileNodes).toContain('src/validator.ts');
expect(fileNodes).toContain('src/db.ts');
expect(fileNodes).toContain('src/formatter.ts');
expect(fileNodes).toContain('src/index.ts');
// --- Verify symbol nodes were created (functions, classes) ---
const symbolNames: string[] = [];
result.graph.forEachNode(n => {
if (['Function', 'Method', 'Class', 'Interface'].includes(n.label)) {
symbolNames.push(n.properties.name);
}
});
expect(symbolNames).toContain('handleRequest');
expect(symbolNames).toContain('validateInput');
expect(symbolNames).toContain('saveToDb');
expect(symbolNames).toContain('formatResponse');
expect(symbolNames).toContain('RequestHandler');
// --- Verify relationships exist ---
const relTypes = new Set<string>();
for (const rel of result.graph.iterRelationships()) {
relTypes.add(rel.type);
}
// Should have at least CONTAINS (structure) and CALLS (call graph)
expect(relTypes).toContain('CONTAINS');
// --- Verify CALLS edges were detected ---
const callEdges: { source: string; target: string }[] = [];
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'CALLS') {
const sourceNode = result.graph.getNode(rel.sourceId);
const targetNode = result.graph.getNode(rel.targetId);
if (sourceNode && targetNode) {
callEdges.push({
source: sourceNode.properties.name,
target: targetNode.properties.name,
});
}
}
}
expect(callEdges.length).toBeGreaterThan(0);
// handleRequest should call validateInput, saveToDb, formatResponse
const handleRequestCalls = callEdges.filter(e => e.source === 'handleRequest');
const calledByHandler = handleRequestCalls.map(e => e.target);
expect(calledByHandler).toContain('validateInput');
expect(calledByHandler).toContain('saveToDb');
expect(calledByHandler).toContain('formatResponse');
// --- Verify IMPORTS edges ---
let importsCount = 0;
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'IMPORTS') importsCount++;
}
expect(importsCount).toBeGreaterThan(0);
});
it('detects communities', async () => {
const result = await runPipelineFromRepo(MINI_REPO, () => {});
expect(result.communityResult).toBeDefined();
expect(result.communityResult.stats.totalCommunities).toBeGreaterThan(0);
// Community nodes should be in the graph
const communityNodes: string[] = [];
result.graph.forEachNode(n => {
if (n.label === 'Community') communityNodes.push(n.properties.name);
});
expect(communityNodes.length).toBeGreaterThan(0);
// MEMBER_OF relationships should exist
let memberOfCount = 0;
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'MEMBER_OF') memberOfCount++;
}
expect(memberOfCount).toBeGreaterThan(0);
});
it('detects execution flows (processes)', async () => {
const result = await runPipelineFromRepo(MINI_REPO, () => {});
expect(result.processResult).toBeDefined();
// With a 4-function call chain (handler -> validator -> db -> formatter),
// there should be at least one process detected
if (result.processResult.stats.totalProcesses > 0) {
const process = result.processResult.processes[0];
// Each process should have valid structure
expect(process.id).toBeTruthy();
expect(process.stepCount).toBeGreaterThanOrEqual(3); // minSteps default
expect(process.trace.length).toBe(process.stepCount);
expect(process.entryPointId).toBeTruthy();
expect(process.terminalId).toBeTruthy();
expect(process.processType).toMatch(/^(intra_community|cross_community)$/);
// Process nodes should be in the graph
const processNode = result.graph.getNode(process.id);
expect(processNode).toBeDefined();
expect(processNode!.label).toBe('Process');
// STEP_IN_PROCESS relationships should exist
let stepCount = 0;
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'STEP_IN_PROCESS' && rel.targetId === process.id) {
stepCount++;
expect(rel.step).toBeGreaterThanOrEqual(1);
}
}
expect(stepCount).toBe(process.stepCount);
}
});
it('reports progress through all 6 phases', async () => {
const phases = new Set<string>();
const onProgress = (p: PipelineProgress) => phases.add(p.phase);
await runPipelineFromRepo(MINI_REPO, onProgress);
expect(phases).toContain('extracting');
expect(phases).toContain('structure');
expect(phases).toContain('parsing');
expect(phases).toContain('communities');
expect(phases).toContain('processes');
expect(phases).toContain('complete');
});
it('returns correct repoPath in result', async () => {
const result = await runPipelineFromRepo(MINI_REPO, () => {});
expect(result.repoPath).toBe(MINI_REPO);
});
});
@@ -0,0 +1,248 @@
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import { loadParser, loadLanguage } from '../../src/core/tree-sitter/parser-loader.js';
import { LANGUAGE_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
import Parser from 'tree-sitter';
const fixturesDir = path.resolve(__dirname, '..', 'fixtures', 'sample-code');
function readFixture(filename: string): string {
return fs.readFileSync(path.join(fixturesDir, filename), 'utf-8');
}
function parseAndQuery(parser: Parser, content: string, queryStr: string) {
const tree = parser.parse(content);
const lang = parser.getLanguage();
const query = new Parser.Query(lang, queryStr);
const matches = query.matches(tree.rootNode);
return { tree, matches };
}
function extractDefinitions(matches: any[]) {
const defs: { type: string; name: string }[] = [];
for (const match of matches) {
for (const capture of match.captures) {
if (capture.name === 'name' && match.captures.some((c: any) =>
c.name.startsWith('definition.'))) {
const defType = match.captures.find((c: any) => c.name.startsWith('definition.'))!.name;
defs.push({ type: defType, name: capture.node.text });
}
}
}
return defs;
}
describe('Tree-sitter multi-language parsing', () => {
let parser: Parser;
beforeAll(async () => {
parser = await loadParser();
});
describe('TypeScript', () => {
it('parses functions, classes, interfaces, methods, and arrow functions', async () => {
await loadLanguage(SupportedLanguages.TypeScript, 'simple.ts');
const content = readFixture('simple.ts');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.TypeScript]);
const defs = extractDefinitions(matches);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.class');
expect(defTypes).toContain('definition.function');
});
});
describe('TSX', () => {
it('parses JSX components with tsx grammar', async () => {
await loadLanguage(SupportedLanguages.TypeScript, 'simple.tsx');
const content = readFixture('simple.tsx');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.TypeScript]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
// Should detect Counter class and Button/useCounter functions
const names = defs.map(d => d.name);
expect(names).toContain('Counter');
});
});
describe('JavaScript', () => {
it('parses class and function declarations', async () => {
await loadLanguage(SupportedLanguages.JavaScript);
const content = readFixture('simple.js');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.JavaScript]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const names = defs.map(d => d.name);
expect(names).toContain('EventEmitter');
expect(names).toContain('createLogger');
});
});
describe('Python', () => {
it('parses class and function definitions', async () => {
await loadLanguage(SupportedLanguages.Python);
const content = readFixture('simple.py');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Python]);
const defs = extractDefinitions(matches);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.class');
expect(defTypes).toContain('definition.function');
});
});
describe('Java', () => {
it('parses class, method, and constructor declarations', async () => {
await loadLanguage(SupportedLanguages.Java);
const content = readFixture('simple.java');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Java]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.class');
expect(defTypes).toContain('definition.method');
});
});
describe('Go', () => {
it('parses function and type declarations', async () => {
await loadLanguage(SupportedLanguages.Go);
const content = readFixture('simple.go');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Go]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.function');
});
});
describe('C', () => {
it('parses function definitions and structs', async () => {
await loadLanguage(SupportedLanguages.C);
const content = readFixture('simple.c');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.C]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.function');
});
});
describe('C++', () => {
it('parses class, function, and namespace declarations', async () => {
await loadLanguage(SupportedLanguages.CPlusPlus);
const content = readFixture('simple.cpp');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.CPlusPlus]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.class');
});
});
describe('C#', () => {
it('parses class, method, and property declarations', async () => {
await loadLanguage(SupportedLanguages.CSharp);
const content = readFixture('simple.cs');
try {
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.CSharp]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
} catch (e: any) {
// Some tree-sitter-c-sharp versions don't support all query node types
expect(e.message).toContain('TSQueryError');
}
});
});
describe('Rust', () => {
it('parses fn, struct, impl, trait, and enum', async () => {
await loadLanguage(SupportedLanguages.Rust);
const content = readFixture('simple.rs');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Rust]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.function');
});
});
describe('PHP', () => {
it('parses class, function, and method declarations', async () => {
await loadLanguage(SupportedLanguages.PHP);
const content = readFixture('simple.php');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.PHP]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.class');
});
});
describe('Swift', () => {
it('parses class, struct, protocol, and function if tree-sitter-swift is available', async () => {
try {
await loadLanguage(SupportedLanguages.Swift);
} catch {
// tree-sitter-swift not installed — skip
return;
}
const content = readFixture('simple.swift');
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.Swift]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
});
it('gracefully handles missing tree-sitter-swift', async () => {
// If Swift is NOT available, loadLanguage should throw
// If it IS available, this test just passes
try {
await loadLanguage(SupportedLanguages.Swift);
} catch (e: any) {
expect(e.message).toContain('Unsupported language');
}
});
});
describe('cross-language assertions', () => {
it('all supported languages produce at least one definition from fixtures', async () => {
const langFixtures: [SupportedLanguages, string, string?][] = [
[SupportedLanguages.TypeScript, 'simple.ts'],
[SupportedLanguages.JavaScript, 'simple.js'],
[SupportedLanguages.Python, 'simple.py'],
[SupportedLanguages.Java, 'simple.java'],
[SupportedLanguages.Go, 'simple.go'],
[SupportedLanguages.C, 'simple.c'],
[SupportedLanguages.CPlusPlus, 'simple.cpp'],
[SupportedLanguages.CSharp, 'simple.cs'],
[SupportedLanguages.Rust, 'simple.rs'],
[SupportedLanguages.PHP, 'simple.php'],
];
for (const [lang, fixture, filePath] of langFixtures) {
await loadLanguage(lang, filePath || fixture);
const content = readFixture(fixture);
try {
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[lang]);
const defs = extractDefinitions(matches);
expect(defs.length, `${lang} (${fixture}) should have definitions`).toBeGreaterThan(0);
} catch (e: any) {
// Some grammars may have query compatibility issues
if (!e.message?.includes('TSQueryError')) throw e;
}
}
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { generateAIContextFiles } from '../../src/cli/ai-context.js';
describe('generateAIContextFiles', () => {
let tmpDir: string;
let storagePath: string;
beforeAll(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-test-'));
storagePath = path.join(tmpDir, '.gitnexus');
await fs.mkdir(storagePath, { recursive: true });
});
afterAll(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch { /* best-effort */ }
});
it('generates context files', async () => {
const stats = {
nodes: 100,
edges: 200,
processes: 10,
};
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
expect(result.files).toBeDefined();
expect(result.files.length).toBeGreaterThan(0);
});
it('creates or updates CLAUDE.md with GitNexus section', async () => {
const stats = { nodes: 50, edges: 100, processes: 5 };
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
const content = await fs.readFile(claudeMdPath, 'utf-8');
expect(content).toContain('gitnexus:start');
expect(content).toContain('gitnexus:end');
expect(content).toContain('TestProject');
});
it('handles empty stats', async () => {
const stats = {};
const result = await generateAIContextFiles(tmpDir, storagePath, 'EmptyProject', stats);
expect(result.files).toBeDefined();
});
it('updates existing CLAUDE.md without duplicating', async () => {
const stats = { nodes: 10 };
// Run twice
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
const content = await fs.readFile(claudeMdPath, 'utf-8');
// Should only have one gitnexus section
const starts = (content.match(/gitnexus:start/g) || []).length;
expect(starts).toBe(1);
});
it('installs skills files', async () => {
const stats = { nodes: 10 };
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
// Should have installed skill files
const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus');
try {
const entries = await fs.readdir(skillsDir, { recursive: true });
expect(entries.length).toBeGreaterThan(0);
} catch {
// Skills dir may not be created if skills source doesn't exist in test context
}
});
});
+95
View File
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createASTCache, type ASTCache } from '../../src/core/ingestion/ast-cache.js';
// Create a minimal mock tree object (mimics Parser.Tree interface)
function mockTree(id: string): any {
return { rootNode: { type: 'program', text: id }, delete: vi.fn() };
}
describe('ASTCache', () => {
let cache: ASTCache;
beforeEach(() => {
cache = createASTCache(3);
});
describe('get / set', () => {
it('returns undefined for cache miss', () => {
expect(cache.get('nonexistent.ts')).toBeUndefined();
});
it('returns cached tree on hit', () => {
const tree = mockTree('test');
cache.set('src/index.ts', tree);
expect(cache.get('src/index.ts')).toBe(tree);
});
it('overwrites existing entry for same key', () => {
const tree1 = mockTree('v1');
const tree2 = mockTree('v2');
cache.set('src/index.ts', tree1);
cache.set('src/index.ts', tree2);
expect(cache.get('src/index.ts')).toBe(tree2);
});
});
describe('LRU eviction', () => {
it('evicts least recently used when capacity exceeded', () => {
cache.set('a.ts', mockTree('a'));
cache.set('b.ts', mockTree('b'));
cache.set('c.ts', mockTree('c'));
// Cache is full (maxSize=3). Adding one more evicts 'a'
cache.set('d.ts', mockTree('d'));
expect(cache.get('a.ts')).toBeUndefined();
expect(cache.get('b.ts')).toBeDefined();
expect(cache.get('d.ts')).toBeDefined();
});
it('accessing an entry makes it recently used', () => {
cache.set('a.ts', mockTree('a'));
cache.set('b.ts', mockTree('b'));
cache.set('c.ts', mockTree('c'));
// Touch 'a' to make it recently used
cache.get('a.ts');
// Now 'b' is LRU
cache.set('d.ts', mockTree('d'));
expect(cache.get('a.ts')).toBeDefined();
expect(cache.get('b.ts')).toBeUndefined();
});
});
describe('clear', () => {
it('removes all entries', () => {
cache.set('a.ts', mockTree('a'));
cache.set('b.ts', mockTree('b'));
cache.clear();
expect(cache.get('a.ts')).toBeUndefined();
expect(cache.get('b.ts')).toBeUndefined();
expect(cache.stats().size).toBe(0);
});
});
describe('stats', () => {
it('reports size and maxSize', () => {
expect(cache.stats()).toEqual({ size: 0, maxSize: 3 });
cache.set('a.ts', mockTree('a'));
expect(cache.stats()).toEqual({ size: 1, maxSize: 3 });
cache.set('b.ts', mockTree('b'));
expect(cache.stats()).toEqual({ size: 2, maxSize: 3 });
});
it('uses default maxSize of 50', () => {
const defaultCache = createASTCache();
expect(defaultCache.stats().maxSize).toBe(50);
});
it('clamps maxSize of 0 to 1 to prevent LRU cache error', () => {
const zeroCache = createASTCache(0);
expect(zeroCache.stats().maxSize).toBe(1);
// Should still function correctly
const tree = mockTree('test');
zeroCache.set('a.ts', tree);
expect(zeroCache.get('a.ts')).toBe(tree);
});
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { searchFTSFromKuzu, type BM25SearchResult } from '../../src/core/search/bm25-index.js';
describe('BM25 search', () => {
describe('searchFTSFromKuzu', () => {
it('returns empty array when KuzuDB is not initialized', async () => {
// Without KuzuDB init, search should return empty (not crash)
const results = await searchFTSFromKuzu('test query');
expect(Array.isArray(results)).toBe(true);
expect(results).toHaveLength(0);
});
it('handles empty query', async () => {
const results = await searchFTSFromKuzu('');
expect(Array.isArray(results)).toBe(true);
});
it('accepts custom limit parameter', async () => {
const results = await searchFTSFromKuzu('test', 5);
expect(Array.isArray(results)).toBe(true);
});
});
describe('BM25SearchResult type', () => {
it('has correct shape', () => {
const result: BM25SearchResult = {
filePath: 'src/index.ts',
score: 1.5,
rank: 1,
};
expect(result.filePath).toBe('src/index.ts');
expect(result.score).toBe(1.5);
expect(result.rank).toBe(1);
});
});
});
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { processCallsFromExtracted } from '../../src/core/ingestion/call-processor.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createImportMap, type ImportMap } from '../../src/core/ingestion/import-processor.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { ExtractedCall } from '../../src/core/ingestion/workers/parse-worker.js';
describe('processCallsFromExtracted', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let symbolTable: ReturnType<typeof createSymbolTable>;
let importMap: ImportMap;
beforeEach(() => {
graph = createKnowledgeGraph();
symbolTable = createSymbolTable();
importMap = createImportMap();
});
it('creates CALLS relationship for same-file resolution', async () => {
symbolTable.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'helper',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].sourceId).toBe('Function:src/index.ts:main');
expect(rels[0].targetId).toBe('Function:src/index.ts:helper');
expect(rels[0].confidence).toBe(0.85);
expect(rels[0].reason).toBe('same-file');
});
it('creates CALLS relationship for import-resolved resolution', async () => {
symbolTable.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function');
importMap.set('src/index.ts', new Set(['src/utils.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'format',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].confidence).toBe(0.9);
expect(rels[0].reason).toBe('import-resolved');
});
it('uses fuzzy-global with higher confidence for unique symbols', async () => {
symbolTable.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function');
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'uniqueFunc',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].confidence).toBe(0.5);
expect(rels[0].reason).toBe('fuzzy-global');
});
it('uses lower confidence for ambiguous fuzzy-global symbols', async () => {
symbolTable.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function');
symbolTable.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function');
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'render',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].confidence).toBe(0.3);
});
it('skips unresolvable calls', async () => {
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'nonExistent',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
expect(graph.relationshipCount).toBe(0);
});
it('prefers same-file over import-resolved', async () => {
// Symbol exists both locally and in imported file
symbolTable.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function');
symbolTable.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function');
importMap.set('src/index.ts', new Set(['src/utils.ts']));
const calls: ExtractedCall[] = [{
filePath: 'src/index.ts',
calledName: 'render',
sourceId: 'Function:src/index.ts:main',
}];
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
const rels = graph.relationships.filter(r => r.type === 'CALLS');
expect(rels).toHaveLength(1);
// Same-file resolution takes priority
expect(rels[0].targetId).toBe('Function:src/index.ts:render');
expect(rels[0].reason).toBe('same-file');
});
it('handles multiple calls from the same file', async () => {
symbolTable.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
symbolTable.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function');
const calls: ExtractedCall[] = [
{ filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' },
{ filePath: 'src/index.ts', calledName: 'bar', sourceId: 'Function:src/index.ts:main' },
];
await processCallsFromExtracted(graph, calls, symbolTable, importMap);
expect(graph.relationships.filter(r => r.type === 'CALLS')).toHaveLength(2);
});
it('calls progress callback', async () => {
symbolTable.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
const calls: ExtractedCall[] = [
{ filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' },
];
const onProgress = vi.fn();
await processCallsFromExtracted(graph, calls, symbolTable, importMap, onProgress);
// Final progress call
expect(onProgress).toHaveBeenCalledWith(1, 1);
});
it('handles empty calls array', async () => {
await processCallsFromExtracted(graph, [], symbolTable, importMap);
expect(graph.relationshipCount).toBe(0);
});
});
@@ -0,0 +1,582 @@
/**
* Unit Tests: LocalBackend callTool dispatch & lifecycle
*
* Tests the callTool dispatch logic, resolveRepo, init/disconnect,
* error cases, and silent failure patterns — all with mocked KuzuDB.
*
* These are pure unit tests that mock the KuzuDB layer to test
* the dispatch and error handling logic in isolation.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// We need to mock the KuzuDB adapter and repo-manager BEFORE importing LocalBackend
vi.mock('../../src/mcp/core/kuzu-adapter.js', () => ({
initKuzu: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
executeParameterized: vi.fn().mockResolvedValue([]),
closeKuzu: vi.fn().mockResolvedValue(undefined),
isKuzuReady: vi.fn().mockReturnValue(true),
}));
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
}));
// Also mock the search modules to avoid loading onnxruntime
vi.mock('../../src/core/search/bm25-index.js', () => ({
searchFTSFromKuzu: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../src/mcp/core/embedder.js', () => ({
embedQuery: vi.fn().mockResolvedValue([]),
getEmbeddingDims: vi.fn().mockReturnValue(384),
}));
import { LocalBackend, isWriteQuery, CYPHER_WRITE_RE } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { initKuzu, executeQuery, executeParameterized, isKuzuReady, closeKuzu } from '../../src/mcp/core/kuzu-adapter.js';
// ─── Helpers ─────────────────────────────────────────────────────────
const MOCK_REPO_ENTRY = {
name: 'test-project',
path: '/tmp/test-project',
storagePath: '/tmp/.gitnexus/test-project',
indexedAt: '2024-06-01T12:00:00Z',
lastCommit: 'abc1234567890',
stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 },
};
function setupSingleRepo() {
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
}
function setupMultipleRepos() {
(listRegisteredRepos as any).mockResolvedValue([
MOCK_REPO_ENTRY,
{
...MOCK_REPO_ENTRY,
name: 'other-project',
path: '/tmp/other-project',
storagePath: '/tmp/.gitnexus/other-project',
},
]);
}
function setupNoRepos() {
(listRegisteredRepos as any).mockResolvedValue([]);
}
// ─── LocalBackend lifecycle ──────────────────────────────────────────
describe('LocalBackend.init', () => {
let backend: LocalBackend;
beforeEach(() => {
backend = new LocalBackend();
vi.clearAllMocks();
});
it('returns true when repos are available', async () => {
setupSingleRepo();
const result = await backend.init();
expect(result).toBe(true);
});
it('returns false when no repos are registered', async () => {
setupNoRepos();
const result = await backend.init();
expect(result).toBe(false);
});
it('calls listRegisteredRepos with validate: true', async () => {
setupSingleRepo();
await backend.init();
expect(listRegisteredRepos).toHaveBeenCalledWith({ validate: true });
});
});
describe('LocalBackend.disconnect', () => {
let backend: LocalBackend;
beforeEach(() => {
backend = new LocalBackend();
vi.clearAllMocks();
});
it('does not throw when no repos are initialized', async () => {
setupNoRepos();
await backend.init();
await expect(backend.disconnect()).resolves.not.toThrow();
});
it('calls closeKuzu on disconnect', async () => {
setupSingleRepo();
await backend.init();
await backend.disconnect();
expect(closeKuzu).toHaveBeenCalled();
});
});
// ─── callTool dispatch ───────────────────────────────────────────────
describe('LocalBackend.callTool', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('routes list_repos without needing repo param', async () => {
const result = await backend.callTool('list_repos', {});
expect(Array.isArray(result)).toBe(true);
expect(result[0].name).toBe('test-project');
});
it('throws for unknown tool name', async () => {
await expect(backend.callTool('nonexistent_tool', {}))
.rejects.toThrow('Unknown tool: nonexistent_tool');
});
it('dispatches query tool', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', { query: 'auth' });
expect(result).toHaveProperty('processes');
expect(result).toHaveProperty('definitions');
});
it('query tool returns error for empty query', async () => {
const result = await backend.callTool('query', { query: '' });
expect(result.error).toContain('query parameter is required');
});
it('query tool returns error for whitespace-only query', async () => {
const result = await backend.callTool('query', { query: ' ' });
expect(result.error).toContain('query parameter is required');
});
it('dispatches cypher tool and blocks write queries', async () => {
const result = await backend.callTool('cypher', { query: 'CREATE (n:Test)' });
expect(result).toHaveProperty('error');
expect(result.error).toContain('Write operations');
});
it('dispatches cypher tool with valid read query', async () => {
(executeQuery as any).mockResolvedValue([
{ name: 'test', filePath: 'src/test.ts' },
]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath LIMIT 5',
});
// formatCypherAsMarkdown returns { markdown, row_count } for tabular results
expect(result).toHaveProperty('markdown');
expect(result).toHaveProperty('row_count');
expect(result.row_count).toBe(1);
});
it('dispatches context tool', async () => {
(executeParameterized as any).mockResolvedValue([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts', startLine: 1, endLine: 10 },
]);
const result = await backend.callTool('context', { name: 'main' });
expect(result.status).toBe('found');
expect(result.symbol.name).toBe('main');
});
it('context tool returns error when name and uid are both missing', async () => {
const result = await backend.callTool('context', {});
expect(result.error).toContain('Either "name" or "uid"');
});
it('context tool returns not-found for missing symbol', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('context', { name: 'doesNotExist' });
expect(result.error).toContain('not found');
});
it('context tool returns disambiguation for multiple matches', async () => {
(executeParameterized as any).mockResolvedValue([
{ id: 'func:main:1', name: 'main', type: 'Function', filePath: 'src/a.ts', startLine: 1, endLine: 5 },
{ id: 'func:main:2', name: 'main', type: 'Function', filePath: 'src/b.ts', startLine: 1, endLine: 5 },
]);
const result = await backend.callTool('context', { name: 'main' });
expect(result.status).toBe('ambiguous');
expect(result.candidates).toHaveLength(2);
});
it('dispatches impact tool', async () => {
// impact() calls executeParameterized to find target, then executeQuery for traversal
(executeParameterized as any).mockResolvedValue([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
]);
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('impact', { target: 'main', direction: 'upstream' });
expect(result).toBeDefined();
expect(result.target).toBeDefined();
});
it('dispatches detect_changes tool', async () => {
// detect_changes calls execFileSync which we haven't mocked at module level,
// so it will throw a git error — that's fine, we test the error path
const result = await backend.callTool('detect_changes', { scope: 'unstaged' });
// Should either return changes or a git error
expect(result).toBeDefined();
expect(result.error || result.summary).toBeDefined();
});
it('dispatches rename tool', async () => {
(executeParameterized as any)
.mockResolvedValueOnce([
{ id: 'func:oldName', name: 'oldName', type: 'Function', filePath: 'src/test.ts', startLine: 1, endLine: 5 },
])
.mockResolvedValue([]);
const result = await backend.callTool('rename', {
symbol_name: 'oldName',
new_name: 'newName',
dry_run: true,
});
expect(result).toBeDefined();
});
it('rename returns error when both symbol_name and symbol_uid are missing', async () => {
const result = await backend.callTool('rename', { new_name: 'newName' });
expect(result.error).toContain('Either symbol_name or symbol_uid');
});
// Legacy tool aliases
it('dispatches "search" as alias for query', async () => {
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('search', { query: 'auth' });
expect(result).toHaveProperty('processes');
});
it('dispatches "explore" as alias for context', async () => {
(executeParameterized as any).mockResolvedValue([
{ id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts', startLine: 1, endLine: 10 },
]);
const result = await backend.callTool('explore', { name: 'main' });
// explore calls context — which may return found or ambiguous depending on mock
expect(result).toBeDefined();
expect(result.status === 'found' || result.symbol || result.error === undefined).toBeTruthy();
});
});
// ─── Repo resolution ────────────────────────────────────────────────
describe('LocalBackend.resolveRepo', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
});
it('resolves single repo without param', async () => {
setupSingleRepo();
await backend.init();
const result = await backend.callTool('list_repos', {});
expect(result).toHaveLength(1);
});
it('throws when no repos are registered', async () => {
setupNoRepos();
await backend.init();
await expect(backend.callTool('query', { query: 'test' }))
.rejects.toThrow('No indexed repositories');
});
it('throws for ambiguous repos without param', async () => {
setupMultipleRepos();
await backend.init();
await expect(backend.callTool('query', { query: 'test' }))
.rejects.toThrow('Multiple repositories indexed');
});
it('resolves repo by name parameter', async () => {
setupMultipleRepos();
await backend.init();
// With repo param, it should resolve correctly
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', {
query: 'auth',
repo: 'test-project',
});
expect(result).toHaveProperty('processes');
});
it('throws for unknown repo name', async () => {
setupSingleRepo();
await backend.init();
await expect(backend.callTool('query', { query: 'test', repo: 'nonexistent' }))
.rejects.toThrow('not found');
});
it('resolves repo case-insensitively', async () => {
setupSingleRepo();
await backend.init();
(executeParameterized as any).mockResolvedValue([]);
// Should match even with different case
const result = await backend.callTool('query', {
query: 'test',
repo: 'Test-Project',
});
expect(result).toHaveProperty('processes');
});
it('refreshes registry on repo miss', async () => {
setupNoRepos();
await backend.init();
// Now make a repo appear
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
// The resolve should re-read the registry and find the new repo
(executeParameterized as any).mockResolvedValue([]);
const result = await backend.callTool('query', {
query: 'test',
repo: 'test-project',
});
expect(result).toHaveProperty('processes');
// listRegisteredRepos should have been called again
expect(listRegisteredRepos).toHaveBeenCalledTimes(2); // once in init, once in refreshRepos
});
});
// ─── getContext ──────────────────────────────────────────────────────
describe('LocalBackend.getContext', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('returns context for single repo without specifying id', () => {
const ctx = backend.getContext();
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
expect(ctx!.stats.fileCount).toBe(10);
expect(ctx!.stats.functionCount).toBe(50);
});
it('returns context by repo id', () => {
const ctx = backend.getContext('test-project');
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
});
it('returns single repo context even with unknown id (single-repo fallback)', () => {
// When only 1 repo is registered, getContext falls through the id check
// and returns the single repo's context. This is intentional behavior.
const ctx = backend.getContext('nonexistent');
// The id doesn't match, but since repos.size === 1, it returns that single context
// This is the actual behavior — test documents it
expect(ctx).not.toBeNull();
expect(ctx!.projectName).toBe('test-project');
});
});
// ─── KuzuDB lazy initialization ──────────────────────────────────────
describe('ensureInitialized', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('calls initKuzu on first tool call', async () => {
(executeParameterized as any).mockResolvedValue([]);
await backend.callTool('query', { query: 'test' });
expect(initKuzu).toHaveBeenCalled();
});
it('retries initKuzu if connection was evicted', async () => {
(executeParameterized as any).mockResolvedValue([]);
// First call initializes
await backend.callTool('query', { query: 'test' });
expect(initKuzu).toHaveBeenCalledTimes(1);
// Simulate idle eviction
(isKuzuReady as any).mockReturnValueOnce(false);
await backend.callTool('query', { query: 'test' });
expect(initKuzu).toHaveBeenCalledTimes(2);
});
it('handles initKuzu failure gracefully', async () => {
(initKuzu as any).mockRejectedValueOnce(new Error('DB locked'));
await expect(backend.callTool('query', { query: 'test' }))
.rejects.toThrow('DB locked');
});
});
// ─── Cypher write blocking through callTool ──────────────────────────
describe('callTool cypher write blocking', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
const writeQueries = [
'CREATE (n:Function {name: "test"})',
'MATCH (n) DELETE n',
'MATCH (n) SET n.name = "hacked"',
'MERGE (n:Function {name: "test"})',
'MATCH (n) REMOVE n.name',
'DROP TABLE Function',
'ALTER TABLE Function ADD COLUMN foo STRING',
'COPY Function FROM "file.csv"',
'MATCH (n) DETACH DELETE n',
];
for (const query of writeQueries) {
it(`blocks write query: ${query.slice(0, 30)}...`, async () => {
const result = await backend.callTool('cypher', { query });
expect(result).toHaveProperty('error');
expect(result.error).toContain('Write operations');
});
}
it('allows read query through callTool', async () => {
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name LIMIT 5',
});
// Should not have error property with write-block message
expect(result.error).toBeUndefined();
});
});
// ─── listRepos ──────────────────────────────────────────────────────
describe('LocalBackend.listRepos', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
});
it('returns empty array when no repos', async () => {
setupNoRepos();
await backend.init();
const repos = await backend.callTool('list_repos', {});
expect(repos).toEqual([]);
});
it('returns repo metadata', async () => {
setupSingleRepo();
await backend.init();
const repos = await backend.callTool('list_repos', {});
expect(repos).toHaveLength(1);
expect(repos[0]).toEqual(expect.objectContaining({
name: 'test-project',
path: '/tmp/test-project',
indexedAt: expect.any(String),
lastCommit: expect.any(String),
}));
});
it('re-reads registry on each listRepos call', async () => {
setupSingleRepo();
await backend.init();
await backend.callTool('list_repos', {});
await backend.callTool('list_repos', {});
// listRegisteredRepos called: once in init, once per listRepos
expect(listRegisteredRepos).toHaveBeenCalledTimes(3);
});
});
// ─── Cypher KuzuDB not ready ────────────────────────────────────────
describe('cypher tool KuzuDB not ready', () => {
let backend: LocalBackend;
beforeEach(async () => {
vi.clearAllMocks();
backend = new LocalBackend();
setupSingleRepo();
await backend.init();
});
it('returns error when KuzuDB is not ready', async () => {
(isKuzuReady as any).mockReturnValue(false);
// initKuzu will succeed but isKuzuReady returns false after ensureInitialized
// Actually ensureInitialized checks isKuzuReady and re-inits — let's make that pass
// then the cypher method checks isKuzuReady again
(isKuzuReady as any)
.mockReturnValueOnce(false) // ensureInitialized check
.mockReturnValueOnce(false); // cypher's own check
const result = await backend.callTool('cypher', {
query: 'MATCH (n) RETURN n LIMIT 1',
});
expect(result.error).toContain('KuzuDB not ready');
});
});
// ─── formatCypherAsMarkdown ──────────────────────────────────────────
describe('cypher result formatting', () => {
let backend: LocalBackend;
beforeEach(async () => {
// Full reset of all mocks to prevent state leaking from other tests
vi.resetAllMocks();
(listRegisteredRepos as any).mockResolvedValue([MOCK_REPO_ENTRY]);
(initKuzu as any).mockResolvedValue(undefined);
(isKuzuReady as any).mockReturnValue(true);
(closeKuzu as any).mockResolvedValue(undefined);
(executeParameterized as any).mockResolvedValue([]);
backend = new LocalBackend();
await backend.init();
});
it('formats tabular results as markdown table', async () => {
(executeQuery as any).mockResolvedValue([
{ name: 'main', filePath: 'src/index.ts' },
{ name: 'helper', filePath: 'src/utils.ts' },
]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name AS name, n.filePath AS filePath',
});
expect(result).toHaveProperty('markdown');
expect(result.markdown).toContain('name');
expect(result.markdown).toContain('main');
expect(result.row_count).toBe(2);
});
it('returns empty array as-is', async () => {
(executeQuery as any).mockResolvedValue([]);
const result = await backend.callTool('cypher', {
query: 'MATCH (n:Function) RETURN n.name LIMIT 0',
});
expect(result).toEqual([]);
});
it('returns error object when cypher fails', async () => {
(executeQuery as any).mockRejectedValue(new Error('Syntax error'));
const result = await backend.callTool('cypher', {
query: 'INVALID CYPHER SYNTAX',
});
expect(result).toHaveProperty('error');
expect(result.error).toContain('Syntax error');
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock all the heavy imports before importing index
vi.mock('../../src/cli/analyze.js', () => ({
analyzeCommand: vi.fn(),
}));
vi.mock('../../src/cli/mcp.js', () => ({
mcpCommand: vi.fn(),
}));
vi.mock('../../src/cli/setup.js', () => ({
setupCommand: vi.fn(),
}));
describe('CLI commands', () => {
describe('version', () => {
it('package.json has a valid version string', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.version).toMatch(/^\d+\.\d+\.\d+/);
});
});
describe('package.json scripts', () => {
it('has test scripts configured', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.scripts.test).toBeDefined();
expect(pkg.default.scripts['test:integration']).toBeDefined();
expect(pkg.default.scripts['test:all']).toBeDefined();
});
it('has build script', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.scripts.build).toBeDefined();
});
});
describe('package.json bin entry', () => {
it('exposes gitnexus binary', async () => {
const pkg = await import('../../package.json', { with: { type: 'json' } });
expect(pkg.default.bin).toBeDefined();
expect(pkg.default.bin.gitnexus || pkg.default.bin).toBeDefined();
});
});
describe('analyzeCommand', () => {
it('is a function', async () => {
const { analyzeCommand } = await import('../../src/cli/analyze.js');
expect(typeof analyzeCommand).toBe('function');
});
});
describe('mcpCommand', () => {
it('is a function', async () => {
const { mcpCommand } = await import('../../src/cli/mcp.js');
expect(typeof mcpCommand).toBe('function');
});
});
describe('setupCommand', () => {
it('is a function', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
expect(typeof setupCommand).toBe('function');
});
});
});
+45
View File
@@ -0,0 +1,45 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../..');
const cliEntry = path.join(repoRoot, 'src/cli/index.ts');
function runHelp(command: string) {
return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, command, '--help'], {
cwd: repoRoot,
encoding: 'utf8',
});
}
describe('CLI help surface', () => {
it('query help keeps advanced search options without importing analyze deps', () => {
const result = runHelp('query');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--context <text>');
expect(result.stdout).toContain('--goal <text>');
expect(result.stdout).toContain('--content');
expect(result.stderr).not.toContain('tree-sitter-kotlin');
});
it('context help keeps optional name and disambiguation flags', () => {
const result = runHelp('context');
expect(result.status).toBe(0);
expect(result.stdout).toContain('context [options] [name]');
expect(result.stdout).toContain('--uid <uid>');
expect(result.stdout).toContain('--file <path>');
});
it('impact help keeps repo and include-tests flags', () => {
const result = runHelp('impact');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--depth <n>');
expect(result.stdout).toContain('--include-tests');
expect(result.stdout).toContain('--repo <name>');
});
});

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