Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c78439d24 | ||
|
|
c5dcc0172a | ||
|
|
0561d24efd | ||
|
|
153262304c | ||
|
|
16cf4c503e | ||
|
|
57951a197b | ||
|
|
63fc4c795f | ||
|
|
5c4fca21c3 | ||
|
|
dd0f5eed7d | ||
|
|
e3d73a7aed | ||
|
|
255e3e79eb | ||
|
|
be4458b650 | ||
|
|
4fed097abb | ||
|
|
4fa395f4b6 | ||
|
|
52277247fe | ||
|
|
ba5de0bde4 | ||
|
|
dc86ea96dc | ||
|
|
d1adc8331a | ||
|
|
80d363f145 | ||
|
|
12be2025f1 | ||
|
|
dcf40da3a8 | ||
|
|
c617350c58 | ||
|
|
71255a096c | ||
|
|
39322e37df |
@@ -30,6 +30,10 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
cache: npm
|
||||
cache-dependency-path: gitnexus/package-lock.json
|
||||
- name: Build gitnexus-shared
|
||||
run: npm install && npm run build
|
||||
working-directory: gitnexus-shared
|
||||
|
||||
- run: npm ci
|
||||
working-directory: gitnexus
|
||||
|
||||
@@ -63,7 +67,22 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Extract release notes from CHANGELOG
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
NOTES=$(awk "/^## \\[$VERSION\\]/{found=1; next} /^## \\[/{if(found) exit} found" gitnexus/CHANGELOG.md)
|
||||
if [ -z "$NOTES" ]; then
|
||||
echo "::warning::No CHANGELOG entry found for v$VERSION, falling back to auto-generated notes"
|
||||
echo "fallback=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "$NOTES" > /tmp/release-notes.md
|
||||
echo "fallback=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
|
||||
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
|
||||
|
||||
@@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus**. Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. For current symbol stats, run `npx gitnexus analyze` and inspect `.gitnexus/meta.json`.
|
||||
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
||||
@@ -58,8 +58,40 @@ This repository is a **monorepo** with two main products: the **CLI / MCP packag
|
||||
| Web UI behavior | `gitnexus-web/src/` (components, workers, graph client). |
|
||||
| CI | `.github/workflows/*.yml`, `.github/actions/setup-gitnexus/`. |
|
||||
|
||||
## Known limitations
|
||||
|
||||
### Overloaded method resolution
|
||||
|
||||
Method and Constructor node IDs include an arity suffix (`#<paramCount>`) to
|
||||
disambiguate overloaded methods. Two overloads with different parameter counts
|
||||
produce distinct graph nodes: `Method:file:Class.method#1` vs
|
||||
`Method:file:Class.method#2`.
|
||||
|
||||
**Remaining limitation — same-arity overloads:** When two overloads share the
|
||||
same parameter count but differ only in types (e.g. `save(int)` vs
|
||||
`save(String)`), they still share a node ID. This is rare in practice; a future
|
||||
enhancement may add type-hash disambiguation for languages with reliable type
|
||||
extraction (see issue #574).
|
||||
|
||||
**Variadic method matching:** When one side is variadic (`parameterCount`
|
||||
undefined) and the other has a fixed count, `METHOD_IMPLEMENTS` edges are
|
||||
emitted with confidence 0.7 instead of 1.0. Variadic methods like
|
||||
`foo(String... args)` may superficially match `foo(String s)` by type but
|
||||
are not guaranteed to be interchangeable across all languages (Java/Kotlin
|
||||
accept this via varargs sugar; TypeScript, C#, Rust do not).
|
||||
|
||||
**Confidence tiering** for `METHOD_IMPLEMENTS` edges:
|
||||
|
||||
| Match quality | Confidence | When |
|
||||
|---|---|---|
|
||||
| Exact parameter types match | 1.0 | Both sides have `parameterTypes` arrays and they match |
|
||||
| Arity (count) matches | 1.0 | Both sides have `parameterCount`, types unavailable |
|
||||
| Variadic vs fixed | 0.7 | One side is variadic, other has fixed count |
|
||||
| Lenient (insufficient info) | 0.7 | One or both sides lack type and count data |
|
||||
|
||||
## Related docs
|
||||
|
||||
- [MIGRATION.md](MIGRATION.md) — breaking changes and migration guidance.
|
||||
- [RUNBOOK.md](RUNBOOK.md) — operational commands and recovery.
|
||||
- [GUARDRAILS.md](GUARDRAILS.md) — safety boundaries for humans and agents.
|
||||
- [TESTING.md](TESTING.md) — how to run tests.
|
||||
|
||||
@@ -10,6 +10,19 @@ All notable changes to GitNexus will be documented in this file.
|
||||
- Added automatic cleanup of stale KuzuDB index files
|
||||
- LadybugDB v0.15 requires explicit VECTOR extension loading for semantic search
|
||||
|
||||
## [1.5.3] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **TypeScript/JavaScript MethodExtractor config** — shared extraction config covering abstract methods, visibility modifiers, async/override keywords, decorators, rest/optional/destructured parameters, and return types (#588) — @compound-ai
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Azure OpenAI compatibility** — use `max_completion_tokens` instead of deprecated `max_tokens` (newer models reject `max_tokens`); skip `temperature` for Azure provider (some models reject non-default values) (#618)
|
||||
- **Simplified Azure interactive setup** — 3 prompts (endpoint, deployment, key) instead of 7 (#618)
|
||||
- **Wiki HTML viewer script injection** — escape `</script>` in embedded JSON so LLM-generated markdown no longer breaks the viewer (#618)
|
||||
- Ensure import rewrites survive npm publish lifecycle
|
||||
|
||||
## [1.4.0] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -49,4 +49,106 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
|
||||
|
||||
## GitNexus rules
|
||||
|
||||
GitNexus MCP rules are in the `<!-- gitnexus:start -->` … `<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
|
||||
GitNexus MCP rules are in the `<!-- gitnexus:start -->` … `<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/GitNexus/clusters` | All functional areas |
|
||||
| `gitnexus://repo/GitNexus/processes` | All execution flows |
|
||||
| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Migration Guide
|
||||
|
||||
## OVERRIDES → METHOD_OVERRIDES (PR #642)
|
||||
|
||||
The `OVERRIDES` relationship type has been renamed to `METHOD_OVERRIDES` for
|
||||
consistency with the new `METHOD_IMPLEMENTS` edge type.
|
||||
|
||||
### Do I need to migrate?
|
||||
|
||||
**No.** Backward compatibility is handled automatically at runtime:
|
||||
|
||||
- `local-backend.ts` dual-reads both `OVERRIDES` and `METHOD_OVERRIDES` in all
|
||||
impact-analysis and context queries. Existing stored graphs with `OVERRIDES`
|
||||
edges continue to return correct results without any manual intervention.
|
||||
- The `REL_TYPES` array in `schema-constants.ts` includes both names so Cypher
|
||||
queries that reference either will work.
|
||||
|
||||
### What happens on re-index?
|
||||
|
||||
Running `npx gitnexus analyze` on a repository produces `METHOD_OVERRIDES`
|
||||
edges going forward. The old `OVERRIDES` edges are replaced as part of the
|
||||
normal full re-index.
|
||||
|
||||
### When will the legacy alias be removed?
|
||||
|
||||
The `OVERRIDES` compat alias will remain until a future major version. Removal
|
||||
will be announced in this file and in the changelog before it happens.
|
||||
@@ -206,11 +206,21 @@ gitnexus clean --all --force # Delete all indexes
|
||||
gitnexus wiki [path] # Generate repository wiki from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <name> <repo> # Add a repo to a group
|
||||
gitnexus group remove <name> <repo> # Remove a repo from a group
|
||||
gitnexus group list [name] # List groups, or show one group's config
|
||||
gitnexus group sync <name> # Extract contracts and match across repos/services
|
||||
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
|
||||
gitnexus group query <name> <q> # Search execution flows across all repos in a group
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
### What Your AI Agent Gets
|
||||
|
||||
**7 tools** exposed via MCP:
|
||||
**16 tools** exposed via MCP (11 per-repo + 5 group):
|
||||
|
||||
| Tool | What It Does | `repo` Param |
|
||||
| ------------------ | ----------------------------------------------------------------- | -------------- |
|
||||
@@ -221,6 +231,11 @@ gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes | Optional |
|
||||
| `rename` | Multi-file coordinated rename with graph + text search | Optional |
|
||||
| `cypher` | Raw Cypher graph queries | Optional |
|
||||
| `group_list` | List configured repository groups | — |
|
||||
| `group_sync` | Extract contracts and match across repos/services | — |
|
||||
| `group_contracts`| Inspect extracted contracts and cross-links | — |
|
||||
| `group_query` | Search execution flows across all repos in a group | — |
|
||||
| `group_status` | Check staleness of repos in a group | — |
|
||||
|
||||
> When only one repo is indexed, the `repo` parameter is optional. With multiple repos, specify which one: `query({query: "auth", repo: "my-app"})`.
|
||||
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
# PR #626 HIGH-Priority Fixes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix 4 HIGH-priority issues from PR #626 code review before merge.
|
||||
|
||||
**Architecture:** Minimal targeted fixes — each task is independent. TDD: tests first, then implementation. No refactoring beyond what's needed.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest, Node.js fs/path APIs
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-02-pr626-high-fixes-design.md`
|
||||
|
||||
**Paths:** All file paths are relative to the monorepo root (`GitNexus/`). Git commands run from the root. The `gitnexus/` prefix is a package subdirectory, not a separate repo.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Path Traversal — Validate Group Name
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/storage.ts:17-19` (getGroupDir) and `:63-68` (createGroupDir)
|
||||
- Test: `gitnexus/test/unit/group/storage.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for validateGroupName**
|
||||
|
||||
In `gitnexus/test/unit/group/storage.test.ts`, add `createGroupDir` and `validateGroupName` to the existing import from `'../../../src/core/group/storage.js'` (line 6-11). Then add these describe blocks at the end of the outer `describe('Group storage', ...)`:
|
||||
|
||||
```typescript
|
||||
describe('validateGroupName', () => {
|
||||
it('test_validateGroupName_traversal_path_throws', () => {
|
||||
expect(() => validateGroupName('../../evil')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_slash_in_name_throws', () => {
|
||||
expect(() => validateGroupName('foo/bar')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_empty_string_throws', () => {
|
||||
expect(() => validateGroupName('')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_starts_with_dash_throws', () => {
|
||||
expect(() => validateGroupName('-leading-dash')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_starts_with_underscore_throws', () => {
|
||||
expect(() => validateGroupName('_leading')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_dots_throws', () => {
|
||||
expect(() => validateGroupName('com.example')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_validateGroupName_valid_alphanumeric_passes', () => {
|
||||
expect(() => validateGroupName('my-group_01')).not.toThrow();
|
||||
});
|
||||
|
||||
it('test_validateGroupName_single_char_passes', () => {
|
||||
expect(() => validateGroupName('A')).not.toThrow();
|
||||
});
|
||||
|
||||
it('test_validateGroupName_all_digits_passes', () => {
|
||||
expect(() => validateGroupName('123')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupDir rejects invalid names', () => {
|
||||
it('test_getGroupDir_traversal_throws', () => {
|
||||
expect(() => getGroupDir(tmpDir, '../../etc')).toThrow(/Invalid group name/);
|
||||
});
|
||||
|
||||
it('test_getGroupDir_valid_name_returns_path', () => {
|
||||
const dir = getGroupDir(tmpDir, 'company');
|
||||
expect(dir).toBe(path.join(tmpDir, 'groups', 'company'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGroupDir rejects invalid names', () => {
|
||||
it('test_createGroupDir_traversal_throws', async () => {
|
||||
await expect(createGroupDir(tmpDir, '../evil')).rejects.toThrow(/Invalid group name/);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
|
||||
Expected: FAIL — `validateGroupName` is not exported, `getGroupDir` does not throw.
|
||||
|
||||
- [ ] **Step 3: Implement validateGroupName and wire into getGroupDir and createGroupDir**
|
||||
|
||||
In `gitnexus/src/core/group/storage.ts`, add the validation function before `getGroupDir` and call it:
|
||||
|
||||
```typescript
|
||||
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
|
||||
export function validateGroupName(name: string): void {
|
||||
if (!GROUP_NAME_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getGroupDir(gitnexusDir: string, groupName: string): string {
|
||||
validateGroupName(groupName);
|
||||
return path.join(gitnexusDir, 'groups', groupName);
|
||||
}
|
||||
```
|
||||
|
||||
`createGroupDir` already calls `getGroupDir` at line 68, so it inherits validation automatically. No change needed in `createGroupDir`.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/storage.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/storage.ts test/unit/group/storage.test.ts
|
||||
git commit -m "fix(group): validate group name to prevent path traversal
|
||||
|
||||
Add validateGroupName() with regex [a-zA-Z0-9][a-zA-Z0-9_-]*.
|
||||
Called in getGroupDir (defense in depth) which covers all CLI entry
|
||||
points: create, add, remove, status, sync.
|
||||
|
||||
Addresses PR #626 review item 1 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Directory Exclusions in Service Boundary Detector
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/service-boundary-detector.ts:24-51` (add constant), `:78` (walkForBoundaries), `:130` (hasSourceFilesInSubdirs)
|
||||
- Test: `gitnexus/test/unit/group/service-boundary-detector.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for excluded directories**
|
||||
|
||||
Add this describe block inside the existing `detectServiceBoundaries` describe in `gitnexus/test/unit/group/service-boundary-detector.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_detect_skips_vendor_directory', async () => {
|
||||
writeFile('services/auth/package.json', '{}');
|
||||
writeFile('services/auth/src/index.ts', '');
|
||||
// vendor should be skipped — its contents should not create a boundary
|
||||
writeFile('vendor/some-dep/package.json', '{}');
|
||||
writeFile('vendor/some-dep/src/lib.go', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/auth');
|
||||
expect(paths).not.toContain('vendor/some-dep');
|
||||
});
|
||||
|
||||
it('test_detect_skips_target_directory', async () => {
|
||||
writeFile('services/api/go.mod', 'module api');
|
||||
writeFile('services/api/main.go', '');
|
||||
writeFile('target/classes/Main.java', '');
|
||||
writeFile('target/pom.xml', '<project/>');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/api');
|
||||
expect(paths).not.toContain('target');
|
||||
});
|
||||
|
||||
it('test_detect_skips_pycache_directory', async () => {
|
||||
writeFile('services/ml/pyproject.toml', '[project]');
|
||||
writeFile('services/ml/model.py', '');
|
||||
// __pycache__ with a marker + source files — would be detected as
|
||||
// a boundary if not excluded, since it has package.json + .py file
|
||||
writeFile('__pycache__/package.json', '{}');
|
||||
writeFile('__pycache__/cached.py', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/ml');
|
||||
expect(paths.every((p) => !p.includes('__pycache__'))).toBe(true);
|
||||
});
|
||||
|
||||
it('test_detect_skips_dotfile_directories_regression', async () => {
|
||||
writeFile('services/api/package.json', '{}');
|
||||
writeFile('services/api/src/index.ts', '');
|
||||
writeFile('.hidden/package.json', '{}');
|
||||
writeFile('.hidden/src/index.ts', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
const paths = boundaries.map((b) => b.servicePath);
|
||||
expect(paths).toContain('services/api');
|
||||
expect(paths).not.toContain('.hidden');
|
||||
});
|
||||
|
||||
it('test_detect_does_not_skip_regular_source_directories', async () => {
|
||||
writeFile('services/api/package.json', '{}');
|
||||
writeFile('services/api/src/index.ts', '');
|
||||
|
||||
const boundaries = await detectServiceBoundaries(tmpDir);
|
||||
|
||||
expect(boundaries).toHaveLength(1);
|
||||
expect(boundaries[0].serviceName).toBe('api');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify `vendor` and `target` tests fail**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
|
||||
Expected: `test_detect_skips_vendor_directory` and `test_detect_skips_target_directory` FAIL (vendor/target not excluded). Other new tests may pass since dotfile exclusion already exists.
|
||||
|
||||
- [ ] **Step 3: Add EXCLUDED_DIRS constant and update both walking functions**
|
||||
|
||||
In `gitnexus/src/core/group/service-boundary-detector.ts`:
|
||||
|
||||
After `SOURCE_EXTENSIONS` (after line 51), add:
|
||||
|
||||
```typescript
|
||||
const EXCLUDED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'target',
|
||||
'build',
|
||||
'dist',
|
||||
'__pycache__',
|
||||
'.venv',
|
||||
'venv',
|
||||
'.tox',
|
||||
'.mypy_cache',
|
||||
'.gradle',
|
||||
'.mvn',
|
||||
'out',
|
||||
'bin',
|
||||
]);
|
||||
```
|
||||
|
||||
In `walkForBoundaries`, replace line 78:
|
||||
```typescript
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
```
|
||||
with:
|
||||
```typescript
|
||||
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
|
||||
```
|
||||
|
||||
In `hasSourceFilesInSubdirs`, replace line 130:
|
||||
```typescript
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
||||
```
|
||||
with:
|
||||
```typescript
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/service-boundary-detector.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/service-boundary-detector.ts test/unit/group/service-boundary-detector.test.ts
|
||||
git commit -m "fix(group): add directory exclusions to service boundary detector
|
||||
|
||||
Add EXCLUDED_DIRS set: vendor, target, build, dist, __pycache__,
|
||||
.venv, venv, .tox, .mypy_cache, .gradle, .mvn, out, bin.
|
||||
Applied in walkForBoundaries and hasSourceFilesInSubdirs.
|
||||
Replaces inline node_modules check.
|
||||
|
||||
Addresses PR #626 review item 3 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Remove Double-Close of LadybugDB Pools
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/cli/group.ts:160` (remove import), `:187-189` (remove finally block body)
|
||||
- Test: `gitnexus/test/unit/group/sync.test.ts` (add pool cleanup test)
|
||||
- Test: `gitnexus/test/integration/group/group-cli.test.ts` (verify no blanket close in source)
|
||||
|
||||
- [ ] **Step 1: Write unit tests for per-id pool cleanup in sync.ts**
|
||||
|
||||
Add to `gitnexus/test/unit/group/sync.test.ts`, inside the existing `describe('syncGroup', ...)`:
|
||||
|
||||
```typescript
|
||||
it('test_syncGroup_closes_only_opened_pools', async () => {
|
||||
const config = makeConfig({
|
||||
'app/backend': 'backend-repo',
|
||||
'app/frontend': 'frontend-repo',
|
||||
});
|
||||
|
||||
const closedIds: string[] = [];
|
||||
|
||||
// Mock initLbug/closeLbug via per-repo override that tracks pool lifecycle
|
||||
const { vi } = await import('vitest');
|
||||
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
||||
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
||||
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockImplementation(async (id?: string) => {
|
||||
if (id) closedIds.push(id);
|
||||
});
|
||||
|
||||
try {
|
||||
await syncGroup(config, {
|
||||
resolveRepoHandle: async (_name, groupPath) => ({
|
||||
id: groupPath.replace(/\//g, '-'),
|
||||
path: groupPath,
|
||||
repoPath: '/tmp/' + groupPath,
|
||||
storagePath: '/tmp/' + groupPath + '/.gitnexus',
|
||||
}),
|
||||
skipWrite: true,
|
||||
}).catch(() => {});
|
||||
// Regardless of extraction errors, closeLbug should be called per id
|
||||
// closeLbug should only receive specific pool ids, never undefined/empty
|
||||
for (const id of closedIds) {
|
||||
expect(id).toBeTruthy();
|
||||
expect(typeof id).toBe('string');
|
||||
}
|
||||
// No blanket close (no-arg call)
|
||||
const blanketCalls = closeSpy.mock.calls.filter((args) => args.length === 0 || !args[0]);
|
||||
expect(blanketCalls).toHaveLength(0);
|
||||
} finally {
|
||||
initSpy.mockRestore();
|
||||
closeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run sync unit test to verify it passes (sync.ts already does per-id cleanup)**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/sync.test.ts`
|
||||
Expected: PASS — sync.ts already cleans up correctly. This test locks the behavior.
|
||||
|
||||
- [ ] **Step 3: Write test verifying CLI source has no blanket closeLbug()**
|
||||
|
||||
Add to `gitnexus/test/integration/group/group-cli.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_sync_command_source_does_not_call_blanket_closeLbug', () => {
|
||||
const cliGroupPath = path.join(repoRoot, 'src', 'cli', 'group.ts');
|
||||
const source = fs.readFileSync(cliGroupPath, 'utf-8');
|
||||
|
||||
// closeLbug() without arguments (blanket close) must not appear.
|
||||
// closeLbug(id) with argument is fine (that's in sync.ts, not here).
|
||||
// Match closeLbug() but not closeLbug(someArg)
|
||||
const blanketClosePattern = /closeLbug\s*\(\s*\)/;
|
||||
expect(source).not.toMatch(blanketClosePattern);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it fails**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
|
||||
Expected: FAIL — `closeLbug()` (no args) exists at line 188.
|
||||
|
||||
- [ ] **Step 5: Remove blanket closeLbug() from cli/group.ts**
|
||||
|
||||
In `gitnexus/src/cli/group.ts`:
|
||||
|
||||
Remove the `closeLbug` import at line 160:
|
||||
```typescript
|
||||
const { closeLbug } = await import('../core/lbug/pool-adapter.js');
|
||||
```
|
||||
|
||||
Replace the try/finally wrapper (lines 162-189):
|
||||
```typescript
|
||||
try {
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await closeLbug().catch(() => {});
|
||||
}
|
||||
```
|
||||
|
||||
Becomes (remove try/finally entirely, since sync.ts handles its own cleanup):
|
||||
```typescript
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/cli/group.ts test/integration/group/group-cli.test.ts test/unit/group/sync.test.ts
|
||||
git commit -m "fix(group): remove blanket closeLbug() from CLI sync command
|
||||
|
||||
sync.ts already closes pools per-id in its finally block.
|
||||
The blanket closeLbug() in cli/group.ts tears down ALL active pools
|
||||
including unrelated ones in MCP server context.
|
||||
|
||||
Addresses PR #626 review item 4 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: gRPC Proto Regex — Brace-Depth Counter
|
||||
|
||||
**Files:**
|
||||
- Modify: `gitnexus/src/core/group/extractors/grpc-extractor.ts:101-130` (parseProtoFile)
|
||||
- Test: `gitnexus/test/unit/group/grpc-extractor.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for nested braces in proto services**
|
||||
|
||||
Add this describe block inside the existing `proto file parsing` describe in `gitnexus/test/unit/group/grpc-extractor.test.ts`:
|
||||
|
||||
```typescript
|
||||
it('test_extract_proto_with_google_api_http_nested_braces', async () => {
|
||||
writeFile(
|
||||
'api/gateway.proto',
|
||||
`syntax = "proto3";
|
||||
package gateway.v1;
|
||||
|
||||
import "google/api/annotations.proto";
|
||||
|
||||
service GatewayService {
|
||||
rpc GetUser (GetUserRequest) returns (UserResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/v1/users/{user_id}"
|
||||
};
|
||||
}
|
||||
rpc CreateUser (CreateUserRequest) returns (UserResponse) {
|
||||
option (google.api.http) = {
|
||||
post: "/v1/users"
|
||||
body: "*"
|
||||
};
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/gateway.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(2);
|
||||
const ids = providers.map((c) => c.contractId).sort();
|
||||
expect(ids).toEqual([
|
||||
'grpc::gateway.v1.GatewayService/CreateUser',
|
||||
'grpc::gateway.v1.GatewayService/GetUser',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_proto_with_multiple_services', async () => {
|
||||
writeFile(
|
||||
'api/multi.proto',
|
||||
`syntax = "proto3";
|
||||
package multi;
|
||||
|
||||
service ServiceA {
|
||||
rpc MethodA (Req) returns (Res);
|
||||
}
|
||||
|
||||
service ServiceB {
|
||||
rpc MethodB1 (Req) returns (Res);
|
||||
rpc MethodB2 (Req) returns (Res);
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/multi.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(3);
|
||||
const ids = providers.map((c) => c.contractId).sort();
|
||||
expect(ids).toEqual([
|
||||
'grpc::multi.ServiceA/MethodA',
|
||||
'grpc::multi.ServiceB/MethodB1',
|
||||
'grpc::multi.ServiceB/MethodB2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_proto_with_nested_option_blocks_in_rpc', async () => {
|
||||
writeFile(
|
||||
'api/nested.proto',
|
||||
`syntax = "proto3";
|
||||
package nested;
|
||||
|
||||
service DeepService {
|
||||
rpc DeepMethod (Req) returns (Res) {
|
||||
option (google.api.http) = {
|
||||
post: "/v1/deep"
|
||||
body: "*"
|
||||
additional_bindings {
|
||||
get: "/v1/deep/{id}"
|
||||
}
|
||||
};
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/nested.proto',
|
||||
);
|
||||
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0].contractId).toBe('grpc::nested.DeepService/DeepMethod');
|
||||
});
|
||||
|
||||
it('test_extract_proto_malformed_unclosed_brace_skips_service', async () => {
|
||||
writeFile(
|
||||
'api/broken.proto',
|
||||
`syntax = "proto3";
|
||||
package broken;
|
||||
|
||||
service IncompleteService {
|
||||
rpc SomeMethod (Req) returns (Res);
|
||||
// Missing closing brace — EOF before depth returns to 0
|
||||
`,
|
||||
);
|
||||
|
||||
// Should not throw; incomplete service is silently skipped
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter(
|
||||
(c) => c.role === 'provider' && c.symbolRef.filePath === 'api/broken.proto',
|
||||
);
|
||||
|
||||
// The old regex would find partial match; the new parser should skip it
|
||||
expect(providers).toHaveLength(0);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify the nested brace test fails**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
|
||||
Expected: `test_extract_proto_with_google_api_http_nested_braces` FAIL — regex stops at first `}` inside the `option` block.
|
||||
|
||||
- [ ] **Step 3: Replace serviceRe regex with extractServiceBlocks function**
|
||||
|
||||
In `gitnexus/src/core/group/extractors/grpc-extractor.ts`, replace the `parseProtoFile` method (lines 101-130):
|
||||
|
||||
```typescript
|
||||
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
|
||||
const pkg = pkgMatch ? pkgMatch[1] : '';
|
||||
|
||||
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
|
||||
const rpcRe = /rpc\s+(\w+)\s*\(/g;
|
||||
let rpcMatch: RegExpExecArray | null;
|
||||
while ((rpcMatch = rpcRe.exec(body)) !== null) {
|
||||
const methodName = rpcMatch[1];
|
||||
const cid = contractId(pkg, serviceName, methodName);
|
||||
out.push(
|
||||
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
|
||||
package: pkg,
|
||||
service: serviceName,
|
||||
method: methodName,
|
||||
source: 'proto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
Add this function before the class (e.g. after `serviceOnlyContractId`, around line 26):
|
||||
|
||||
```typescript
|
||||
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
|
||||
const results: Array<{ name: string; body: string }> = [];
|
||||
const headerRe = /service\s+(\w+)\s*\{/g;
|
||||
let headerMatch: RegExpExecArray | null;
|
||||
|
||||
while ((headerMatch = headerRe.exec(content)) !== null) {
|
||||
const serviceName = headerMatch[1];
|
||||
const bodyStart = headerMatch.index + headerMatch[0].length;
|
||||
let depth = 1;
|
||||
let pos = bodyStart;
|
||||
|
||||
while (pos < content.length && depth > 0) {
|
||||
const ch = content[pos];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
|
||||
// If EOF before depth returns to 0, skip incomplete service
|
||||
if (depth !== 0) continue;
|
||||
|
||||
// body is between opening { (consumed by regex) and closing } (pos is one past it)
|
||||
const body = content.slice(bodyStart, pos - 1);
|
||||
results.push({ name: serviceName, body });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/grpc-extractor.test.ts`
|
||||
Expected: ALL PASS (including existing regression tests)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add src/core/group/extractors/grpc-extractor.ts test/unit/group/grpc-extractor.test.ts
|
||||
git commit -m "fix(group): replace gRPC proto regex with brace-depth counter
|
||||
|
||||
The serviceRe regex used [^}]* which stopped at the first '}'.
|
||||
Proto services with google.api.http annotations contain nested {}
|
||||
blocks, causing methods to be missed.
|
||||
|
||||
New extractServiceBlocks() uses a brace-depth counter (init depth=1
|
||||
after opening {, scan char-by-char). Malformed protos with unclosed
|
||||
braces are silently skipped.
|
||||
|
||||
Addresses PR #626 review item 2 (HIGH).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Run Full Test Suite
|
||||
|
||||
- [ ] **Step 1: Run all group-related tests**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/unit/group/ test/integration/group/`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 2: Run full test suite to catch regressions**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run`
|
||||
Expected: ALL PASS, 0 failures
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `cd gitnexus && npx tsc --noEmit`
|
||||
Expected: No errors
|
||||
|
||||
---
|
||||
|
||||
### Task 6: CLI Integration Smoke Test
|
||||
|
||||
- [ ] **Step 1: Add CLI smoke test for path traversal**
|
||||
|
||||
Add to `gitnexus/test/integration/group/group-cli.test.ts` inside the existing `group CLI` describe:
|
||||
|
||||
```typescript
|
||||
it('test_create_with_invalid_name_fails', () => {
|
||||
const result = runGroup(['create', '../../evil']);
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain('Invalid group name');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test**
|
||||
|
||||
Run: `cd gitnexus && npx vitest run test/integration/group/group-cli.test.ts`
|
||||
Expected: ALL PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd gitnexus && git add test/integration/group/group-cli.test.ts
|
||||
git commit -m "test(group): add CLI smoke test for path traversal rejection
|
||||
|
||||
Verifies that 'group create ../../evil' fails with Invalid group name.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
@@ -0,0 +1,175 @@
|
||||
# PR #626 HIGH-Priority Fixes Design
|
||||
|
||||
**Date:** 2026-04-02
|
||||
**PR:** abhigyanpatwari/GitNexus#626 — Intra-repo service communication tracking
|
||||
**Scope:** 4 HIGH-priority issues identified by abhigyanpatwari and xkonjin
|
||||
**Approach:** Minimal targeted fixes (option A) — no refactoring, no scope creep
|
||||
|
||||
---
|
||||
|
||||
## Fix 1: Path Traversal via Group Name
|
||||
|
||||
**File:** `gitnexus/src/core/group/storage.ts`
|
||||
**Risk:** A group name like `../../etc` creates directories outside the intended path.
|
||||
|
||||
### Solution
|
||||
|
||||
Add `validateGroupName(name: string): void` that enforces `/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/`.
|
||||
|
||||
- Call in `createGroupDir` (primary entry point)
|
||||
- Call in `getGroupDir` (defense in depth)
|
||||
- Throw descriptive error on invalid names
|
||||
|
||||
**Legacy:** Groups already on disk with names outside this pattern are not auto-renamed; only new `create` / resolved paths are validated.
|
||||
|
||||
### Why regex over path.resolve + startsWith
|
||||
|
||||
- abhigyanpatwari explicitly requested `[a-zA-Z0-9_-]`
|
||||
- Stricter: disallows spaces, dots, Unicode edge cases
|
||||
- Simpler to reason about
|
||||
|
||||
### Tests
|
||||
|
||||
- `../../evil` throws
|
||||
- `foo/bar` throws
|
||||
- Empty string throws
|
||||
- `my-group_01` passes
|
||||
- `A` (single char) passes
|
||||
- CLI smoke: one integration test that hits `getGroupDir` / `createGroupDir` (e.g. `group create` or `group add`) with an invalid name proves wiring for every subcommand that resolves a group through storage
|
||||
|
||||
### CLI/API entry points accepting groupName
|
||||
|
||||
All paths flow through `getGroupDir` (which validates), so coverage is implicit. For reference:
|
||||
|
||||
| Command | Entry | Calls |
|
||||
|---------|-------|-------|
|
||||
| `group create` | `cli/group.ts` action | `createGroupDir` -> `getGroupDir` |
|
||||
| `group add` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group remove` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group list` | `cli/group.ts` action | reads `groups/` dir directly — no traversal risk (reads, not writes) |
|
||||
| `group status` | `cli/group.ts` action | `getGroupDir` |
|
||||
| `group sync` | `cli/group.ts` action | `getGroupDir` |
|
||||
|
||||
**`listGroups`:** Reads directory names from disk without validation. Not a write path, so no traversal risk. May surface manually-created directories with non-conforming names — accepted as-is, not in scope.
|
||||
|
||||
---
|
||||
|
||||
## Fix 2: gRPC Proto Regex -> Brace-Depth Counter
|
||||
|
||||
**File:** `gitnexus/src/core/group/extractors/grpc-extractor.ts`
|
||||
**Risk:** `serviceRe = /service\s+(\w+)\s*\{([^}]*)}/gs` stops at first `}`. Proto services with `google.api.http` annotations inside RPCs contain nested `{ }` blocks.
|
||||
|
||||
### Solution
|
||||
|
||||
Replace `serviceRe` regex with `extractServiceBlocks(content: string): Array<{ name: string; body: string }>`:
|
||||
|
||||
1. Use regex only to find `service <Name> {` start positions (regex consumes the opening `{`)
|
||||
2. Initialise depth to 1 immediately after the opening `{`
|
||||
3. Scan forward char by char: `{` -> depth++, `}` -> depth--; collect into body
|
||||
4. Stop when depth reaches 0 (the matching closing `}`)
|
||||
5. Return name + body pairs
|
||||
|
||||
Inner `rpcRe` regex remains unchanged — it operates on the already-extracted body.
|
||||
|
||||
**Malformed input:** If EOF is reached before `depth` returns to 0, skip the incomplete service (do not add to results). Lock this in the test.
|
||||
|
||||
**Scope limitation (v1):** Brace-depth only — no lexer for string literals or comments containing `{`/`}`. Sufficient for `google.api.http` annotations. Known false positive: braces inside `//` comments or quoted strings within proto options. Accepted for v1; a proper proto lexer is out of scope.
|
||||
|
||||
### Tests
|
||||
|
||||
- Proto with single service, no nesting (regression)
|
||||
- Proto with `google.api.http` nested braces inside RPC options
|
||||
- Proto with multiple services
|
||||
- Proto with nested `option` blocks inside RPC (e.g. `google.api.http`)
|
||||
- Malformed proto with unclosed brace (graceful handling)
|
||||
|
||||
---
|
||||
|
||||
## Fix 3: Directory Exclusions in Service Boundary Detector
|
||||
|
||||
**File:** `gitnexus/src/core/group/service-boundary-detector.ts`
|
||||
**Risk:** Walks entire repo tree, only skipping dotfiles and `node_modules`. Extremely slow on repos with `vendor/`, `target/`, `__pycache__/`, `.venv/`.
|
||||
|
||||
### Solution
|
||||
|
||||
Create `EXCLUDED_DIRS` as a `Set<string>` (alongside existing `SERVICE_MARKERS`, `SOURCE_EXTENSIONS`), for example:
|
||||
|
||||
```text
|
||||
node_modules, vendor, target, build, dist,
|
||||
__pycache__, .venv, venv, .tox, .mypy_cache,
|
||||
.gradle, .mvn, out, bin
|
||||
```
|
||||
|
||||
(Implement as `new Set([...])` — the list above is the membership, not a string literal.)
|
||||
|
||||
Apply in both:
|
||||
- `walkForBoundaries` (line 77-78) — replace current inline `=== 'node_modules'` check with `EXCLUDED_DIRS.has(entry.name)`
|
||||
- `hasSourceFilesInSubdirs` (line 130) — replace `entry.name !== 'node_modules'` with `!EXCLUDED_DIRS.has(entry.name)`
|
||||
|
||||
Note: remove the old `=== 'node_modules'` literal from both locations — it is covered by `EXCLUDED_DIRS`.
|
||||
Dotfile exclusion (`.` prefix) remains as a separate check since it's a pattern, not a name.
|
||||
Exclusions apply only to `isDirectory()` entries — file names are never checked against `EXCLUDED_DIRS`.
|
||||
|
||||
**Tradeoff:** Rare layouts that keep source under names like `out/` or `bin/` will be skipped; accepted for performance on typical monorepos.
|
||||
|
||||
**Case sensitivity:** `Set.has` is case-sensitive (matches current `=== 'node_modules'` behavior). Windows case-insensitive FS not handled — accepted as-is, consistent with existing code.
|
||||
|
||||
### Tests
|
||||
|
||||
- Directory named `vendor/` is skipped
|
||||
- Directory named `target/` is skipped
|
||||
- Directory named `__pycache__/` is skipped
|
||||
- Regular source directories are NOT skipped
|
||||
- Dotfile directories still skipped (regression)
|
||||
|
||||
---
|
||||
|
||||
## Fix 4: Double-Close of LadybugDB Pools
|
||||
|
||||
**Files:**
|
||||
- `gitnexus/src/core/group/sync.ts` (lines 155-157) — per-id cleanup (KEEP)
|
||||
- `gitnexus/src/cli/group.ts` (line 188) — blanket `closeLbug()` (REMOVE)
|
||||
|
||||
**Risk:** In MCP server context, `closeLbug()` without arguments tears down ALL active pools, including ones from unrelated operations.
|
||||
|
||||
### Solution
|
||||
|
||||
Remove the `closeLbug()` call (no arguments) from `cli/group.ts` finally block. The per-id cleanup in `sync.ts` is sufficient:
|
||||
|
||||
```typescript
|
||||
// sync.ts — KEEP: cleans up only pools opened by this sync
|
||||
finally {
|
||||
for (const id of [...new Set(openPoolIds)]) {
|
||||
await closeLbug(id).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// cli/group.ts — REMOVE: blanket close that kills all pools
|
||||
finally {
|
||||
await closeLbug().catch(() => {}); // DELETE THIS
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `closeLbug` import from `cli/group.ts` — after removing the `finally` call it has no remaining usages.
|
||||
|
||||
### Tests (unit level — mock pool adapter)
|
||||
|
||||
- `syncGroup` closes only the pools it opened (mock `closeLbug`, assert called with specific ids)
|
||||
- Two-pool scenario: sync opens pools A and B, both closed in finally; pool C (opened elsewhere) not touched
|
||||
- CLI `sync` command does not call blanket `closeLbug()` (verify no zero-arg call in source — static check or grep-based test)
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- JSON -> LadybugDB migration (tracked in #606)
|
||||
- MEDIUM/LOW issues (items 5-10 from review summary)
|
||||
- Test gap coverage beyond what's needed for these 4 fixes
|
||||
- Any refactoring or architectural changes
|
||||
|
||||
## Execution Order
|
||||
|
||||
Fixes are independent — can be implemented in parallel or any order.
|
||||
Recommended order for review clarity: 1 -> 3 -> 4 -> 2 (simplest to most complex).
|
||||
@@ -97,7 +97,8 @@ export type RelationshipType =
|
||||
| 'CONTAINS'
|
||||
| 'CALLS'
|
||||
| 'INHERITS'
|
||||
| 'OVERRIDES'
|
||||
| 'METHOD_OVERRIDES'
|
||||
| 'METHOD_IMPLEMENTS'
|
||||
| 'IMPORTS'
|
||||
| 'USES'
|
||||
| 'DEFINES'
|
||||
|
||||
@@ -41,6 +41,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
|
||||
[SupportedLanguages.Kotlin]: ['.kt', '.kts'],
|
||||
[SupportedLanguages.Swift]: ['.swift'],
|
||||
[SupportedLanguages.Dart]: ['.dart'],
|
||||
[SupportedLanguages.Vue]: ['.vue'],
|
||||
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
|
||||
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
|
||||
|
||||
@@ -98,6 +99,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.Kotlin]: 'kotlin',
|
||||
[SupportedLanguages.Swift]: 'swift',
|
||||
[SupportedLanguages.Dart]: 'dart',
|
||||
[SupportedLanguages.Vue]: 'typescript',
|
||||
[SupportedLanguages.Cobol]: 'cobol',
|
||||
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export enum SupportedLanguages {
|
||||
Kotlin = 'kotlin',
|
||||
Swift = 'swift',
|
||||
Dart = 'dart',
|
||||
Vue = 'vue',
|
||||
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
|
||||
Cobol = 'cobol',
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ export const REL_TYPES = [
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
'ACCESSES',
|
||||
'OVERRIDES',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES', // Legacy compat alias — kept until all stored indexes are migrated
|
||||
'METHOD_IMPLEMENTS',
|
||||
'MEMBER_OF',
|
||||
'STEP_IN_PROCESS',
|
||||
'HANDLES_ROUTE',
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for heartbeat disconnect/reconnect behavior.
|
||||
*
|
||||
* Verifies the key regression: when the heartbeat fails, the UI shows a
|
||||
* "reconnecting" banner instead of resetting to the onboarding screen.
|
||||
*
|
||||
* Strategy: block /api/heartbeat via route interception BEFORE loading the
|
||||
* graph. The heartbeat EventSource can never connect, so onReconnecting
|
||||
* fires on the first retry attempt. This reliably tests the banner behavior
|
||||
* without depending on setOffline timing (which varies across CI environments).
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) return;
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available');
|
||||
return;
|
||||
}
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Heartbeat Reconnect', () => {
|
||||
test('shows reconnecting banner instead of onboarding reset when heartbeat is unavailable', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Block the heartbeat BEFORE navigating — the EventSource will fail
|
||||
// immediately on every connection attempt, triggering onReconnecting.
|
||||
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
|
||||
|
||||
// Load the app and connect to a repo (all other endpoints work normally)
|
||||
await page.goto('/');
|
||||
|
||||
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
|
||||
try {
|
||||
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await landingCard.click();
|
||||
} catch {
|
||||
// auto-connect may skip the landing screen
|
||||
}
|
||||
|
||||
// Wait for graph to load (heartbeat is blocked, but graph loads fine)
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The reconnecting banner should appear (heartbeat is failing)
|
||||
const banner = page.getByText('Server connection lost');
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The graph canvas should STILL be visible — NOT reset to onboarding
|
||||
await expect(page.locator('canvas').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('banner clears when heartbeat becomes available', async ({ page }) => {
|
||||
// Start with heartbeat blocked
|
||||
await page.route('**/api/heartbeat', (route) => route.abort('connectionrefused'));
|
||||
|
||||
await page.goto('/');
|
||||
const landingCard = page.locator('[data-testid="landing-repo-card"]').first();
|
||||
try {
|
||||
await landingCard.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await landingCard.click();
|
||||
} catch {
|
||||
// auto-connect may skip the landing screen
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify banner appears
|
||||
const banner = page.getByText('Server connection lost');
|
||||
await expect(banner).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Unblock heartbeat — the real server is running, so reconnect will succeed
|
||||
await page.unroute('**/api/heartbeat');
|
||||
|
||||
// Banner should disappear as heartbeat reconnects
|
||||
await expect(banner).not.toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Graph should still be there
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* E2E tests for multi-repo scoping and URL persistence.
|
||||
*
|
||||
* Verifies that:
|
||||
* - Connecting via ?server= loads data and sets ?project= in the URL
|
||||
* - The repo name appears in the UI after connecting
|
||||
* - F5 with ?server=&project= reconnects to the correct repo
|
||||
*
|
||||
* Runs against the single indexed repo in CI — validates the plumbing
|
||||
* works end-to-end even with one repo.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:4747';
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL ?? 'http://localhost:5173';
|
||||
|
||||
let firstRepoName: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (process.env.E2E) {
|
||||
// Still need to fetch the repo name for assertions
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/repos`);
|
||||
const repos = await res.json();
|
||||
firstRepoName = repos[0]?.name ?? '';
|
||||
} catch {
|
||||
firstRepoName = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [backendRes, frontendRes] = await Promise.allSettled([
|
||||
fetch(`${BACKEND_URL}/api/repos`),
|
||||
fetch(FRONTEND_URL),
|
||||
]);
|
||||
if (
|
||||
backendRes.status === 'rejected' ||
|
||||
(backendRes.status === 'fulfilled' && !backendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'gitnexus serve not available');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
frontendRes.status === 'rejected' ||
|
||||
(frontendRes.status === 'fulfilled' && !frontendRes.value.ok)
|
||||
) {
|
||||
test.skip(true, 'Vite dev server not available');
|
||||
return;
|
||||
}
|
||||
if (backendRes.status === 'fulfilled') {
|
||||
const repos = await backendRes.value.json();
|
||||
if (!repos.length) {
|
||||
test.skip(true, 'No indexed repos');
|
||||
return;
|
||||
}
|
||||
firstRepoName = repos[0].name;
|
||||
}
|
||||
} catch {
|
||||
test.skip(true, 'servers not available');
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Multi-Repo Scoping', () => {
|
||||
test('auto-connect via ?server= sets ?project= in URL', async ({ page }) => {
|
||||
// Navigate with ?server= param (the bookmarkable shortcut)
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
|
||||
// Wait for graph to load
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// URL should now contain ?project= with the repo name
|
||||
const url = new URL(page.url());
|
||||
const project = url.searchParams.get('project');
|
||||
expect(project).toBeTruthy();
|
||||
expect(project).toBe(firstRepoName);
|
||||
});
|
||||
|
||||
test('?server= is preserved in URL for F5 recovery', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// URL should still have ?server=
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('server')).toBeTruthy();
|
||||
|
||||
// F5 should reconnect (not show onboarding)
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test('node count in status bar matches backend data', async ({ page }) => {
|
||||
await page.goto(`/?server=${encodeURIComponent(BACKEND_URL)}`);
|
||||
await expect(page.locator('[data-testid="status-ready"]')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Fetch expected node count from backend
|
||||
const res = await fetch(`${BACKEND_URL}/api/repo?repo=${encodeURIComponent(firstRepoName)}`);
|
||||
const repoInfo = await res.json();
|
||||
const expectedNodes = repoInfo.stats?.nodes;
|
||||
|
||||
if (expectedNodes) {
|
||||
// Status bar shows node count — use the status-ready area to avoid
|
||||
// matching multiple elements (file tree, header may also show counts)
|
||||
const statusBar = page.locator('footer');
|
||||
const nodeText = statusBar.getByText(/\d+ nodes/).first();
|
||||
await expect(nodeText).toBeVisible({ timeout: 10_000 });
|
||||
const text = await nodeText.textContent();
|
||||
const displayedNodes = parseInt(text?.match(/(\d+)\s*nodes/)?.[1] ?? '0', 10);
|
||||
expect(displayedNodes).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
+78
-47
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppStateProvider, useAppState } from './hooks/useAppState';
|
||||
import { DropZone } from './components/DropZone';
|
||||
import { LoadingOverlay } from './components/LoadingOverlay';
|
||||
@@ -36,7 +36,6 @@ const AppContent = () => {
|
||||
refreshLLMSettings,
|
||||
initializeAgent,
|
||||
startEmbeddingsWithFallback,
|
||||
embeddingStatus,
|
||||
codeReferences,
|
||||
selectedNode,
|
||||
isCodePanelOpen,
|
||||
@@ -45,17 +44,27 @@ const AppContent = () => {
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
setCurrentRepo,
|
||||
} = useAppState();
|
||||
|
||||
const graphCanvasRef = useRef<GraphCanvasHandle>(null);
|
||||
const [serverDisconnected, setServerDisconnected] = useState(false);
|
||||
|
||||
const handleServerConnect = useCallback(
|
||||
async (result: ConnectResult): Promise<void> => {
|
||||
// Extract project name from repoPath
|
||||
// Use the canonical repo name from the server response so all subsequent
|
||||
// backend calls (queries, search, grep, readFile) scope to this repo.
|
||||
const repoName = result.repoInfo.name;
|
||||
const repoPath = result.repoInfo.repoPath ?? result.repoInfo.path;
|
||||
const parts = (repoPath || '').split('/').filter((p) => p && !p.startsWith('.'));
|
||||
const projectName = parts[parts.length - 1] || parts[0] || 'server-project';
|
||||
const projectName =
|
||||
repoName || repoPath?.split('/').filter(Boolean).pop() || 'server-project';
|
||||
setProjectName(projectName);
|
||||
setCurrentRepo(projectName);
|
||||
|
||||
// Update URL so F5 / bookmarks preserve which repo is open
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('project', projectName);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
|
||||
// Build KnowledgeGraph from server data for visualization
|
||||
const graph = createKnowledgeGraph();
|
||||
@@ -80,10 +89,18 @@ const AppContent = () => {
|
||||
console.warn('Failed to initialize agent:', err);
|
||||
}
|
||||
},
|
||||
[setViewMode, setGraph, setProjectName, initializeAgent, startEmbeddingsWithFallback],
|
||||
[
|
||||
setViewMode,
|
||||
setGraph,
|
||||
setProjectName,
|
||||
setCurrentRepo,
|
||||
initializeAgent,
|
||||
startEmbeddingsWithFallback,
|
||||
],
|
||||
);
|
||||
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut)
|
||||
// Auto-connect when ?server query param is present (bookmarkable shortcut).
|
||||
// Also reads ?project= to connect to a specific repo.
|
||||
const autoConnectRan = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoConnectRan.current) return;
|
||||
@@ -91,9 +108,12 @@ const AppContent = () => {
|
||||
if (!params.has('server')) return;
|
||||
autoConnectRan.current = true;
|
||||
|
||||
// Clean the URL so a refresh won't re-trigger
|
||||
const cleanUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', cleanUrl);
|
||||
const serverUrl = params.get('server') || window.location.origin;
|
||||
const projectParam = params.get('project') || undefined;
|
||||
|
||||
// Keep ?server= in the URL so F5 reconnects to the same server.
|
||||
// autoConnectRan.current prevents re-trigger within the same session.
|
||||
// handleServerConnect() will add/update ?project= after connecting.
|
||||
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
@@ -103,36 +123,39 @@ const AppContent = () => {
|
||||
});
|
||||
setViewMode('loading');
|
||||
|
||||
const serverUrl = params.get('server') || window.location.origin;
|
||||
|
||||
const baseUrl = normalizeServerUrl(serverUrl);
|
||||
|
||||
connectToServer(serverUrl, (phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 5,
|
||||
message: 'Connecting to server...',
|
||||
detail: 'Validating server',
|
||||
});
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: pct,
|
||||
message: 'Downloading graph...',
|
||||
detail: `${mb} MB downloaded`,
|
||||
});
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 97,
|
||||
message: 'Processing...',
|
||||
detail: 'Extracting file contents',
|
||||
});
|
||||
}
|
||||
})
|
||||
connectToServer(
|
||||
serverUrl,
|
||||
(phase, downloaded, total) => {
|
||||
if (phase === 'validating') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 5,
|
||||
message: 'Connecting to server...',
|
||||
detail: 'Validating server',
|
||||
});
|
||||
} else if (phase === 'downloading') {
|
||||
const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50;
|
||||
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: pct,
|
||||
message: 'Downloading graph...',
|
||||
detail: `${mb} MB downloaded`,
|
||||
});
|
||||
} else if (phase === 'extracting') {
|
||||
setProgress({
|
||||
phase: 'extracting',
|
||||
percent: 97,
|
||||
message: 'Processing...',
|
||||
detail: 'Extracting file contents',
|
||||
});
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
projectParam,
|
||||
)
|
||||
.then(async (result) => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
@@ -169,21 +192,18 @@ const AppContent = () => {
|
||||
|
||||
// ── Server heartbeat: detect when server goes down while exploring ────────
|
||||
// Uses SSE (EventSource) for instant detection — no polling delay.
|
||||
// On disconnect: show a reconnecting banner instead of resetting to onboarding.
|
||||
// The heartbeat retries indefinitely with capped backoff and recovers automatically.
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'exploring') return;
|
||||
|
||||
const cleanup = connectHeartbeat(
|
||||
() => {}, // onConnect — already connected, no action needed
|
||||
() => {
|
||||
// Server went down — return to onboarding
|
||||
setViewMode('onboarding');
|
||||
setGraph(null);
|
||||
setProgress(null);
|
||||
},
|
||||
() => setServerDisconnected(false),
|
||||
() => setServerDisconnected(true),
|
||||
);
|
||||
|
||||
return cleanup;
|
||||
}, [viewMode, setViewMode, setGraph, setProgress]);
|
||||
}, [viewMode]);
|
||||
|
||||
// Render based on view mode
|
||||
if (viewMode === 'onboarding') {
|
||||
@@ -196,7 +216,12 @@ const AppContent = () => {
|
||||
await handleServerConnect(result);
|
||||
setProgress(null);
|
||||
if (serverUrl) {
|
||||
setServerBaseUrl(normalizeServerUrl(serverUrl));
|
||||
const base = normalizeServerUrl(serverUrl);
|
||||
setServerBaseUrl(base);
|
||||
// Add ?server= so F5 reconnects to this server
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('server', base);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -268,6 +293,12 @@ const AppContent = () => {
|
||||
|
||||
<StatusBar />
|
||||
|
||||
{serverDisconnected && (
|
||||
<div className="fixed bottom-12 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-yellow-500/30 bg-yellow-900/80 px-4 py-2 text-sm text-yellow-200 shadow-lg backdrop-blur">
|
||||
Server connection lost — reconnecting…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings Panel (modal) */}
|
||||
<SettingsPanel
|
||||
isOpen={isSettingsPanelOpen}
|
||||
|
||||
@@ -54,6 +54,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
clearCodeReferences,
|
||||
setSelectedNode,
|
||||
codeReferenceFocus,
|
||||
projectName,
|
||||
} = useAppState();
|
||||
|
||||
const nodeById = useMemo(() => {
|
||||
@@ -174,7 +175,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
return () => {
|
||||
rafIds.forEach((id) => cancelAnimationFrame(id));
|
||||
};
|
||||
}, [codeReferenceFocus?.ts, aiReferences]);
|
||||
}, [codeReferenceFocus, aiReferences]);
|
||||
|
||||
const refsWithSnippets = useMemo(() => {
|
||||
return aiReferences.map((ref) => {
|
||||
@@ -223,10 +224,11 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
const isWholeFile = selectedIsFile || startLine === undefined;
|
||||
|
||||
const options = isWholeFile
|
||||
? undefined
|
||||
? { repo: projectName }
|
||||
: {
|
||||
startLine: Math.max(0, startLine - CONTEXT_LINES),
|
||||
endLine: (endLine ?? startLine) + CONTEXT_LINES,
|
||||
repo: projectName,
|
||||
};
|
||||
|
||||
readFile(selectedFilePath, options)
|
||||
@@ -251,6 +253,7 @@ export const CodeReferencesPanel = ({ onFocusNode }: CodeReferencesPanelProps) =
|
||||
selectedNode?.properties?.startLine,
|
||||
selectedNode?.properties?.endLine,
|
||||
selectedIsFile,
|
||||
projectName,
|
||||
]);
|
||||
|
||||
// Scroll to the selected node's startLine after content loads
|
||||
|
||||
@@ -145,6 +145,7 @@ interface AppState {
|
||||
availableRepos: BackendRepo[];
|
||||
setAvailableRepos: (repos: BackendRepo[]) => void;
|
||||
switchRepo: (repoName: string) => Promise<void>;
|
||||
setCurrentRepo: (repoName: string) => void;
|
||||
|
||||
// Worker API (shared across app)
|
||||
runQuery: (cypher: string) => Promise<any[]>;
|
||||
@@ -456,6 +457,10 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
// Backend client — direct HTTP calls (no Worker/Comlink)
|
||||
const repoRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const setCurrentRepo = useCallback((repoName: string) => {
|
||||
repoRef.current = repoName;
|
||||
}, []);
|
||||
|
||||
const runQuery = useCallback(async (cypher: string): Promise<any[]> => {
|
||||
return backendRunQuery(cypher, repoRef.current);
|
||||
}, []);
|
||||
@@ -1077,6 +1082,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
setProjectName(pName);
|
||||
repoRef.current = pName;
|
||||
|
||||
// Update URL so F5 / bookmarks open the correct repo
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('project', pName);
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
|
||||
const newGraph = createKnowledgeGraph();
|
||||
for (const node of result.nodes) newGraph.addNode(node);
|
||||
for (const rel of result.relationships) newGraph.addRelationship(rel);
|
||||
@@ -1219,6 +1229,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
||||
availableRepos,
|
||||
setAvailableRepos,
|
||||
switchRepo,
|
||||
setCurrentRepo,
|
||||
runQuery,
|
||||
isDatabaseReady,
|
||||
// Embedding state and methods
|
||||
|
||||
@@ -302,16 +302,26 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Connect an SSE heartbeat to the backend. Fires `onDisconnect` when the
|
||||
* server goes down (after one retry to avoid false positives from transient
|
||||
* network hiccups). Returns a cleanup function.
|
||||
* Connect an SSE heartbeat to the backend. Retries indefinitely with capped
|
||||
* exponential backoff so transient hiccups don't reset the UI.
|
||||
*
|
||||
* - `onConnect` fires on every successful (re)connection.
|
||||
* - `onReconnecting` fires on the first retry after a drop — use it to show
|
||||
* a "reconnecting" banner while keeping the current view intact.
|
||||
*
|
||||
* Returns a cleanup function that tears down the EventSource and timers.
|
||||
*/
|
||||
export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void): (() => void) => {
|
||||
export const connectHeartbeat = (
|
||||
onConnect: () => void,
|
||||
onReconnecting: () => void,
|
||||
): (() => void) => {
|
||||
let closed = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let es: EventSource | null = null;
|
||||
let attempt = 0;
|
||||
const MAX_RETRIES = 3;
|
||||
/** Whether we've already fired onReconnecting for the current drop. */
|
||||
let notifiedReconnecting = false;
|
||||
const MAX_BACKOFF_MS = 15_000;
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
@@ -319,6 +329,7 @@ export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void
|
||||
es.onopen = () => {
|
||||
if (!closed) {
|
||||
attempt = 0;
|
||||
notifiedReconnecting = false;
|
||||
onConnect();
|
||||
}
|
||||
};
|
||||
@@ -326,13 +337,15 @@ export const connectHeartbeat = (onConnect: () => void, onDisconnect: () => void
|
||||
es?.close();
|
||||
es = null;
|
||||
if (closed) return;
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = 1_000 * Math.pow(2, attempt);
|
||||
attempt++;
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
} else {
|
||||
onDisconnect();
|
||||
|
||||
if (!notifiedReconnecting) {
|
||||
notifiedReconnecting = true;
|
||||
onReconnecting();
|
||||
}
|
||||
|
||||
const delay = Math.min(1_000 * Math.pow(2, attempt), MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { connectHeartbeat } from '../../src/services/backend-client';
|
||||
|
||||
// Mock EventSource to simulate SSE behavior
|
||||
class MockEventSource {
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
let lastEventSource: MockEventSource | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
lastEventSource = null;
|
||||
vi.stubGlobal(
|
||||
'EventSource',
|
||||
vi.fn().mockImplementation(() => {
|
||||
lastEventSource = new MockEventSource();
|
||||
return lastEventSource;
|
||||
}),
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('connectHeartbeat', () => {
|
||||
it('calls onConnect when EventSource opens', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
lastEventSource!.onopen!();
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
expect(onReconnecting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onReconnecting on first error, then retries', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Simulate connection drop
|
||||
lastEventSource!.onerror!();
|
||||
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
expect(lastEventSource!.closed).toBe(true);
|
||||
|
||||
// Advance past first retry delay (1s)
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
// A new EventSource should have been created
|
||||
expect(EventSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fires onReconnecting only once per disconnect', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// First error
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Second retry fires error again
|
||||
vi.advanceTimersByTime(1_000);
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
|
||||
|
||||
// Third retry fires error
|
||||
vi.advanceTimersByTime(2_000);
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
|
||||
});
|
||||
|
||||
it('retries indefinitely instead of giving up after 3 attempts', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Simulate 10 consecutive failures — should never stop retrying
|
||||
for (let i = 0; i < 10; i++) {
|
||||
lastEventSource!.onerror!();
|
||||
// Advance past the max backoff (15s) to ensure the next retry fires
|
||||
vi.advanceTimersByTime(16_000);
|
||||
}
|
||||
|
||||
// Should have created 11 EventSources (1 initial + 10 retries)
|
||||
expect(EventSource).toHaveBeenCalledTimes(11);
|
||||
});
|
||||
|
||||
it('resets reconnecting state when connection recovers', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Drop
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Retry succeeds
|
||||
vi.advanceTimersByTime(1_000);
|
||||
lastEventSource!.onopen!();
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
|
||||
// Drop again — should fire onReconnecting again (reset after recovery)
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caps backoff at 15 seconds', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Fail many times to push backoff past the cap
|
||||
for (let i = 0; i < 6; i++) {
|
||||
lastEventSource!.onerror!();
|
||||
// The delay for attempt i is min(1000 * 2^i, 15000)
|
||||
// i=0: 1s, i=1: 2s, i=2: 4s, i=3: 8s, i=4: 15s (capped), i=5: 15s (capped)
|
||||
vi.advanceTimersByTime(16_000);
|
||||
}
|
||||
|
||||
// All retries should have fired — 7 EventSources total
|
||||
expect(EventSource).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('stops retrying when cleanup is called', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
lastEventSource!.onerror!();
|
||||
cleanup();
|
||||
|
||||
// Advance time — no new EventSource should be created
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(EventSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
All notable changes to GitNexus will be documented in this file.
|
||||
|
||||
## [1.5.2] - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
- **`gitnexus-shared` module not found** — `gitnexus-shared` was a `file:` workspace dependency never published to npm, causing `ERR_MODULE_NOT_FOUND` when installing `gitnexus` globally. The build now bundles shared code into `dist/_shared/` and rewrites imports to relative paths (#613)
|
||||
- **v1.5.1 publish regression** — npm's `prepare` lifecycle ran `tsc` after `prepack`, overwriting the rewritten imports before packing; both scripts now run the full build so the final tarball is always correct
|
||||
|
||||
## [1.5.1] - 2026-04-01 [YANKED]
|
||||
|
||||
### Fixed
|
||||
- Incomplete fix for `gitnexus-shared` bundling — `prepare` script overwrote rewritten imports during publish
|
||||
|
||||
## [1.5.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -164,6 +164,16 @@ gitnexus clean # Delete index for current repo
|
||||
gitnexus clean --all --force # Delete all indexes
|
||||
gitnexus wiki [path] # Generate LLM-powered docs from knowledge graph
|
||||
gitnexus wiki --model <model> # Wiki with custom LLM model (default: gpt-4o-mini)
|
||||
|
||||
# Repository groups (multi-repo / monorepo service tracking)
|
||||
gitnexus group create <name> # Create a repository group
|
||||
gitnexus group add <name> <repo> # Add a repo to a group
|
||||
gitnexus group remove <name> <repo> # Remove a repo from a group
|
||||
gitnexus group list [name] # List groups, or show one group's config
|
||||
gitnexus group sync <name> # Extract contracts and match across repos/services
|
||||
gitnexus group contracts <name> # Inspect extracted contracts and cross-links
|
||||
gitnexus group query <name> <q> # Search execution flows across all repos in a group
|
||||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
## Remote Embeddings
|
||||
|
||||
Generated
+31
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.3",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
@@ -16,12 +16,12 @@
|
||||
"commander": "^12.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"glob": "^11.0.0",
|
||||
"graphology": "^0.25.4",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lru-cache": "^11.0.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
@@ -47,9 +47,11 @@
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^4.0.18"
|
||||
@@ -65,6 +67,7 @@
|
||||
},
|
||||
"../gitnexus-shared": {
|
||||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2"
|
||||
}
|
||||
@@ -1992,6 +1995,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
@@ -2285,6 +2295,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -3566,6 +3582,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.3",
|
||||
"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",
|
||||
@@ -38,7 +38,7 @@
|
||||
"vendor"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "node scripts/build.js",
|
||||
"serve": "tsx src/cli/index.ts serve",
|
||||
"dev": "tsx watch src/cli/index.ts",
|
||||
"test": "vitest run",
|
||||
@@ -46,11 +46,10 @@
|
||||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"prepare": "npm run build",
|
||||
"prepack": "npm run build && chmod +x dist/cli/index.js"
|
||||
"prepare": "node scripts/build.js",
|
||||
"prepack": "node scripts/build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@huggingface/transformers": "^3.0.0",
|
||||
"@ladybugdb/core": "^0.15.2",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
@@ -58,11 +57,13 @@
|
||||
"commander": "^12.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"glob": "^11.0.0",
|
||||
"graphology": "^0.25.4",
|
||||
"graphology-indices": "^0.17.0",
|
||||
"graphology-utils": "^2.3.0",
|
||||
"ignore": "^7.0.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lru-cache": "^11.0.0",
|
||||
"mnemonist": "^0.39.0",
|
||||
"onnxruntime-node": "^1.24.0",
|
||||
@@ -87,9 +88,11 @@
|
||||
"tree-sitter-swift": "^0.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build script that compiles gitnexus and inlines gitnexus-shared into the dist.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Build gitnexus-shared (tsc)
|
||||
* 2. Build gitnexus (tsc)
|
||||
* 3. Copy gitnexus-shared/dist → dist/_shared
|
||||
* 4. Rewrite bare 'gitnexus-shared' specifiers → relative paths
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const SHARED_ROOT = path.resolve(ROOT, '..', 'gitnexus-shared');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
const SHARED_DEST = path.join(DIST, '_shared');
|
||||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' });
|
||||
|
||||
// ── 2. Build gitnexus ──────────────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus…');
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' });
|
||||
|
||||
// ── 3. Copy shared dist ────────────────────────────────────────────
|
||||
console.log('[build] copying shared module into dist/_shared…');
|
||||
fs.cpSync(path.join(SHARED_ROOT, 'dist'), SHARED_DEST, { recursive: true });
|
||||
|
||||
// ── 4. Rewrite imports ─────────────────────────────────────────────
|
||||
console.log('[build] rewriting gitnexus-shared imports…');
|
||||
let rewritten = 0;
|
||||
|
||||
function rewriteFile(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (!content.includes('gitnexus-shared')) return;
|
||||
|
||||
const relDir = path.relative(path.dirname(filePath), SHARED_DEST);
|
||||
// Always use posix separators and point to the package index
|
||||
const relImport = relDir.split(path.sep).join('/') + '/index.js';
|
||||
|
||||
const updated = content
|
||||
.replace(/from\s+['"]gitnexus-shared['"]/g, `from '${relImport}'`)
|
||||
.replace(/import\(\s*['"]gitnexus-shared['"]\s*\)/g, `import('${relImport}')`);
|
||||
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(filePath, updated);
|
||||
rewritten++;
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir, extensions, cb) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full, extensions, cb);
|
||||
} else if (extensions.some((ext) => entry.name.endsWith(ext))) {
|
||||
cb(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(DIST, ['.js', '.d.ts'], rewriteFile);
|
||||
|
||||
// ── 5. Make CLI entry executable ────────────────────────────────────
|
||||
const cliEntry = path.join(DIST, 'cli', 'index.js');
|
||||
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
|
||||
|
||||
console.log(`[build] done — rewrote ${rewritten} files.`);
|
||||
@@ -42,10 +42,28 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
|
||||
* - Exact tool commands with parameters — vague directives get ignored
|
||||
* - Self-review checklist — forces model to verify its own work
|
||||
*/
|
||||
async function findGroupsContainingRegistryName(registryName: string): Promise<string[]> {
|
||||
const { listGroups, getDefaultGitnexusDir, getGroupDir } =
|
||||
await import('../core/group/storage.js');
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const names = await listGroups();
|
||||
const hits: string[] = [];
|
||||
for (const g of names) {
|
||||
try {
|
||||
const config = await loadGroupConfig(getGroupDir(getDefaultGitnexusDir(), g));
|
||||
if (Object.values(config.repos).some((r) => r === registryName)) hits.push(config.name);
|
||||
} catch {
|
||||
// skip invalid or unreadable groups
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function generateGitNexusContent(
|
||||
projectName: string,
|
||||
stats: RepoStats,
|
||||
generatedSkills?: GeneratedSkillInfo[],
|
||||
groupNames?: string[],
|
||||
): string {
|
||||
const generatedRows =
|
||||
generatedSkills && generatedSkills.length > 0
|
||||
@@ -155,7 +173,15 @@ To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`sta
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after \`git commit\` and \`git merge\`.
|
||||
|
||||
## CLI
|
||||
${
|
||||
groupNames && groupNames.length > 0
|
||||
? `## Cross-Repo Groups
|
||||
|
||||
This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}** (see \`~/.gitnexus/groups/\`). For blast radius across repository boundaries, use MCP tools \`group_impact\`, \`group_sync\`, \`group_query\`, \`group_contracts\`, \`group_status\`, and \`group_list\`. From the terminal: \`npx gitnexus group list\`, \`npx gitnexus group sync <name>\`, \`npx gitnexus group impact <name> --target <symbol> --repo <group-path>\`.
|
||||
|
||||
`
|
||||
: ''
|
||||
}## CLI
|
||||
|
||||
${skillsTable}
|
||||
|
||||
@@ -305,7 +331,8 @@ export async function generateAIContextFiles(
|
||||
generatedSkills?: GeneratedSkillInfo[],
|
||||
options?: AIContextOptions,
|
||||
): Promise<{ files: string[] }> {
|
||||
const content = generateGitNexusContent(projectName, stats, generatedSkills);
|
||||
const groupNames = await findGroupsContainingRegistryName(projectName);
|
||||
const content = generateGitNexusContent(projectName, stats, generatedSkills, groupNames);
|
||||
const createdFiles: string[] = [];
|
||||
|
||||
if (!options?.skipAgentsMd) {
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// gitnexus/src/cli/group.ts
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
|
||||
export function registerGroupCommands(program: Command): void {
|
||||
const group = program
|
||||
.command('group')
|
||||
.description('Manage repository groups for cross-index impact analysis');
|
||||
|
||||
group
|
||||
.command('create <name>')
|
||||
.description('Create a new group with template group.yaml')
|
||||
.option('--force', 'Overwrite existing group')
|
||||
.action(async (name: string, opts: { force?: boolean }) => {
|
||||
const { createGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
|
||||
const dir = await createGroupDir(getDefaultGitnexusDir(), name, opts.force);
|
||||
console.log(`Created group "${name}" at ${dir}`);
|
||||
console.log('Edit group.yaml to add repos, then run: gitnexus group sync ' + name);
|
||||
});
|
||||
|
||||
group
|
||||
.command('add <group> <groupPath> <registryName>')
|
||||
.description(
|
||||
'Add a repo to a group. <groupPath> = hierarchy path (e.g. hr/hiring/backend), <registryName> = name from registry',
|
||||
)
|
||||
.action(async (groupName: string, groupPath: string, registryName: string) => {
|
||||
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const path = await import('node:path');
|
||||
const fs = await import('node:fs/promises');
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
config.repos[groupPath] = registryName;
|
||||
|
||||
await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
|
||||
console.log(`Added ${registryName} as "${groupPath}" to group "${groupName}"`);
|
||||
console.log(`Run: gitnexus group sync ${groupName}`);
|
||||
});
|
||||
|
||||
group
|
||||
.command('remove <group> <path>')
|
||||
.description('Remove a repo from a group')
|
||||
.action(async (groupName: string, repoPath: string) => {
|
||||
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const path = await import('node:path');
|
||||
const fs = await import('node:fs/promises');
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
if (!(repoPath in config.repos)) {
|
||||
console.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
delete config.repos[repoPath];
|
||||
await fs.writeFile(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
|
||||
console.log(`Removed "${repoPath}" from group "${groupName}"`);
|
||||
});
|
||||
|
||||
group
|
||||
.command('list [name]')
|
||||
.description('List all groups or details of one')
|
||||
.action(async (name?: string) => {
|
||||
const { listGroups, getDefaultGitnexusDir, getGroupDir } =
|
||||
await import('../core/group/storage.js');
|
||||
if (!name) {
|
||||
const groups = await listGroups();
|
||||
if (groups.length === 0) {
|
||||
console.log('No groups configured. Create one with: gitnexus group create <name>');
|
||||
return;
|
||||
}
|
||||
console.log('Groups:');
|
||||
groups.forEach((g) => console.log(` ${g}`));
|
||||
return;
|
||||
}
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
console.log(`Group: ${config.name}`);
|
||||
if (config.description) console.log(`Description: ${config.description}`);
|
||||
console.log(`\nRepos (${Object.keys(config.repos).length}):`);
|
||||
for (const [p, id] of Object.entries(config.repos)) {
|
||||
console.log(` ${p} -> ${id}`);
|
||||
}
|
||||
if (config.links.length > 0) {
|
||||
console.log(`\nManifest links (${config.links.length}):`);
|
||||
for (const link of config.links) {
|
||||
console.log(` ${link.from} -> ${link.to} [${link.type}: ${link.contract}]`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
group
|
||||
.command('status <name>')
|
||||
.description('Check staleness of group and repos')
|
||||
.action(async (name: string) => {
|
||||
const { readContractRegistry, getGroupDir, getDefaultGitnexusDir } =
|
||||
await import('../core/group/storage.js');
|
||||
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
|
||||
console.log(
|
||||
`Group: ${name}${registry ? ` (last sync: ${registry.generatedAt})` : ' (never synced)'}\n`,
|
||||
);
|
||||
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
await backend.init();
|
||||
const raw = await backend.getGroupService().groupStatus({ name });
|
||||
const st = raw as {
|
||||
repos?: Record<
|
||||
string,
|
||||
{
|
||||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
missing: boolean;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
>;
|
||||
missingRepos?: string[];
|
||||
};
|
||||
|
||||
console.log(' Repo index / contracts staleness:');
|
||||
for (const [repoPath, row] of Object.entries(st.repos || {})) {
|
||||
if (row.missing) {
|
||||
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
|
||||
continue;
|
||||
}
|
||||
const idx = row.indexStale
|
||||
? `STALE (${row.commitsBehind ?? '?'} commits behind)`
|
||||
: 'OK ';
|
||||
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
|
||||
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
|
||||
}
|
||||
if ((st.missingRepos || []).length > 0) {
|
||||
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
|
||||
}
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
group
|
||||
.command('sync <name>')
|
||||
.description('Sync Contract Registry — extract contracts and build cross-links')
|
||||
.option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)')
|
||||
.option('--exact-only', 'Exact match only')
|
||||
.option('--allow-stale', 'Skip stale index warnings')
|
||||
.option('--verbose', 'Show each cross-link detail')
|
||||
.option('--json', 'JSON output')
|
||||
.action(async (name: string, opts: Record<string, boolean | undefined>) => {
|
||||
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const { syncGroup } = await import('../core/group/sync.js');
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
group
|
||||
.command('query <name> <query>')
|
||||
.description('Search execution flows across all repos in a group')
|
||||
.option('--subgroup <path>', 'Limit search scope')
|
||||
.option('--limit <n>', 'Max merged results', '5')
|
||||
.option('--json', 'JSON output')
|
||||
.action(
|
||||
async (
|
||||
name: string,
|
||||
queryText: string,
|
||||
opts: Record<string, string | boolean | undefined>,
|
||||
) => {
|
||||
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
||||
|
||||
const limit = parseInt(String(opts.limit ?? '5'), 10) || 5;
|
||||
const subgroup = opts.subgroup as string | undefined;
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
await backend.init();
|
||||
|
||||
console.log(`Searching "${queryText}" across group "${name}"...\n`);
|
||||
|
||||
const raw = await backend.getGroupService().groupQuery({
|
||||
name,
|
||||
query: queryText,
|
||||
limit,
|
||||
subgroup,
|
||||
});
|
||||
const merged = raw as {
|
||||
results: Array<Record<string, unknown>>;
|
||||
per_repo: Array<{ repo: string; count: number }>;
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(raw, null, 2));
|
||||
} else {
|
||||
console.log(`Results (top ${merged.results.length}):\n`);
|
||||
for (const p of merged.results) {
|
||||
const label = (p.summary || p.heuristicLabel || p.name || 'unnamed') as string;
|
||||
console.log(` [${p._repo}] ${label} (rrf: ${(p._rrf_score as number).toFixed(4)})`);
|
||||
}
|
||||
if (merged.results.length === 0) {
|
||||
console.log(' No matching execution flows found.');
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
group
|
||||
.command('contracts <name>')
|
||||
.description('Inspect Contract Registry')
|
||||
.option('--type <type>', 'Filter by contract type')
|
||||
.option('--repo <repo>', 'Filter by repo')
|
||||
.option('--unmatched', 'Show only unmatched contracts')
|
||||
.option('--json', 'JSON output')
|
||||
.action(async (name: string, opts: Record<string, string | boolean | undefined>) => {
|
||||
const { LocalBackend } = await import('../mcp/local/local-backend.js');
|
||||
|
||||
const backend = new LocalBackend();
|
||||
try {
|
||||
await backend.init();
|
||||
const raw = await backend.getGroupService().groupContracts({
|
||||
name,
|
||||
type: opts.type as string | undefined,
|
||||
repo: opts.repo as string | undefined,
|
||||
unmatchedOnly: Boolean(opts.unmatched),
|
||||
});
|
||||
|
||||
if (raw && typeof raw === 'object' && 'error' in raw) {
|
||||
console.error(String((raw as { error: string }).error));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { contracts, crossLinks } = raw as {
|
||||
contracts: Array<{
|
||||
role: string;
|
||||
contractId: string;
|
||||
repo: string;
|
||||
symbolRef: { name: string };
|
||||
}>;
|
||||
crossLinks: Array<{
|
||||
from: { repo: string };
|
||||
to: { repo: string };
|
||||
matchType: string;
|
||||
confidence: number;
|
||||
contractId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
|
||||
} else {
|
||||
console.log(`Contracts (${contracts.length}):`);
|
||||
for (const c of contracts) {
|
||||
console.log(` [${c.role}] ${c.contractId} (${c.repo}) ${c.symbolRef.name}`);
|
||||
}
|
||||
console.log(`\nCross-links (${crossLinks.length}):`);
|
||||
for (const l of crossLinks) {
|
||||
console.log(
|
||||
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Command } from 'commander';
|
||||
import { createRequire } from 'node:module';
|
||||
import { createLazyAction } from './lazy-action.js';
|
||||
import { registerGroupCommands } from './group.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const pkg = _require('../../package.json');
|
||||
@@ -148,4 +149,6 @@ program
|
||||
.option('--idle-timeout <seconds>', 'Auto-shutdown after N seconds idle (0 = disabled)', '0')
|
||||
.action(createLazyAction(() => import('./eval-server.js'), 'evalServerCommand'));
|
||||
|
||||
registerGroupCommands(program);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { execFile } from 'child_process';
|
||||
import { execFile, execFileSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { glob } from 'glob';
|
||||
@@ -25,11 +25,44 @@ interface SetupResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the `gitnexus` binary if it's installed
|
||||
* globally (or via npm -g / yarn global). Returns null when not found.
|
||||
*/
|
||||
function resolveGitnexusBin(): string | null {
|
||||
try {
|
||||
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
||||
const resolved = execFileSync(cmd, ['gitnexus'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.split('\n')[0]
|
||||
.trim();
|
||||
return resolved || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The MCP server entry for all editors.
|
||||
* On Windows, npx must be invoked via cmd /c since it's a .cmd script.
|
||||
*
|
||||
* Prefers the globally-installed `gitnexus` binary (starts in ~1 s) over
|
||||
* `npx -y gitnexus@latest` (cold-cache install of native deps can take
|
||||
* >60 s, exceeding Claude Code's 30 s MCP connection timeout).
|
||||
*
|
||||
* Falls back to npx when the binary isn't on PATH — e.g. first-time
|
||||
* users who ran `npx gitnexus analyze` but haven't done `npm i -g`.
|
||||
*/
|
||||
function getMcpEntry() {
|
||||
const bin = resolveGitnexusBin();
|
||||
|
||||
if (bin) {
|
||||
return { command: bin, args: ['mcp'] };
|
||||
}
|
||||
|
||||
// Fallback: npx (works without a global install, but slow cold-start)
|
||||
if (process.platform === 'win32') {
|
||||
return {
|
||||
command: 'cmd',
|
||||
|
||||
+18
-58
@@ -232,65 +232,28 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
||||
|
||||
llmConfig = { ...llmConfig, provider: 'cursor', model, apiKey: '', baseUrl: '' };
|
||||
} else if (choice === '3') {
|
||||
// Azure OpenAI guided setup
|
||||
console.log('\n Azure OpenAI setup.');
|
||||
console.log(
|
||||
' You need: your resource name, deployment name, and API key from the Azure portal.\n',
|
||||
);
|
||||
// Azure OpenAI guided setup — minimal prompts
|
||||
console.log('\n Azure OpenAI setup.\n');
|
||||
|
||||
const resourceName = (
|
||||
await prompt(' Azure resource name (e.g. my-openai-resource): ')
|
||||
).trim();
|
||||
if (!resourceName) {
|
||||
console.log('\n No resource name provided. Aborting.\n');
|
||||
const endpoint = (
|
||||
await prompt(' Endpoint URL (e.g. https://my-resource.openai.azure.com): ')
|
||||
)
|
||||
.trim()
|
||||
.replace(/\/+$/, '');
|
||||
if (!endpoint) {
|
||||
console.log('\n No endpoint provided. Aborting.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const deploymentName = (
|
||||
await prompt(' Deployment name (the name you gave your model deployment): ')
|
||||
).trim();
|
||||
const deploymentName = (await prompt(' Deployment name: ')).trim();
|
||||
if (!deploymentName) {
|
||||
console.log('\n No deployment name provided. Aborting.\n');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Offer v1 or legacy URL
|
||||
console.log('\n API format:');
|
||||
console.log(' [1] v1 API — recommended (no api-version needed)');
|
||||
console.log(' [2] Legacy — uses api-version query param\n');
|
||||
const apiFormat = await prompt(' Select format (1/2, default: 1): ');
|
||||
|
||||
let azureApiVersion: string | undefined;
|
||||
let azureBaseUrl: string;
|
||||
if (apiFormat === '2') {
|
||||
const versionInput = await prompt(' api-version (default: 2024-10-21): ');
|
||||
azureApiVersion = versionInput || '2024-10-21';
|
||||
azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/deployments/${deploymentName}`;
|
||||
} else {
|
||||
azureBaseUrl = `https://${resourceName}.openai.azure.com/openai/v1`;
|
||||
azureApiVersion = undefined;
|
||||
}
|
||||
|
||||
defaultModel = deploymentName;
|
||||
|
||||
// Ask if this is a reasoning model deployment
|
||||
const reasoningAnswer = await prompt(
|
||||
' Is this a reasoning model (o1, o3, o4-mini)? (y/N): ',
|
||||
);
|
||||
const isReasoningModelDeployment = ['y', 'yes'].includes(reasoningAnswer.toLowerCase());
|
||||
|
||||
if (isReasoningModelDeployment) {
|
||||
console.log(
|
||||
' Note: temperature and max_tokens will be omitted for this deployment (Azure reasoning model requirement).\n',
|
||||
);
|
||||
}
|
||||
|
||||
const modelInput = await prompt(` Model / deployment name (default: ${defaultModel}): `);
|
||||
const model = modelInput || defaultModel;
|
||||
|
||||
// API key
|
||||
// API key — use env var if available
|
||||
const envKey = process.env.GITNEXUS_API_KEY || process.env.OPENAI_API_KEY || '';
|
||||
let azureKey: string;
|
||||
if (envKey) {
|
||||
@@ -311,26 +274,23 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
|
||||
return;
|
||||
}
|
||||
|
||||
// Save Azure config including optional apiVersion and isReasoningModel
|
||||
const azureConfig: Parameters<typeof saveCLIConfig>[0] = {
|
||||
// Always use v1 API format — no need for api-version
|
||||
const azureBaseUrl = `${endpoint}/openai/v1`;
|
||||
|
||||
await saveCLIConfig({
|
||||
apiKey: azureKey,
|
||||
baseUrl: azureBaseUrl,
|
||||
model,
|
||||
model: deploymentName,
|
||||
provider: 'azure',
|
||||
isReasoningModel: isReasoningModelDeployment,
|
||||
};
|
||||
if (azureApiVersion) azureConfig.apiVersion = azureApiVersion;
|
||||
await saveCLIConfig(azureConfig);
|
||||
});
|
||||
console.log(' Config saved to ~/.gitnexus/config.json\n');
|
||||
|
||||
llmConfig = {
|
||||
...llmConfig,
|
||||
apiKey: azureKey,
|
||||
baseUrl: azureBaseUrl,
|
||||
model,
|
||||
model: deploymentName,
|
||||
provider: 'azure',
|
||||
apiVersion: azureApiVersion,
|
||||
isReasoningModel: isReasoningModelDeployment,
|
||||
};
|
||||
} else {
|
||||
// OpenAI-compatible provider (OpenAI, OpenRouter, Custom)
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
|
||||
if (!repo) return '';
|
||||
|
||||
// Lazy-load lbug adapter (skip unnecessary init)
|
||||
const { initLbug, executeQuery, isLbugReady } = await import('../../mcp/core/lbug-adapter.js');
|
||||
const { initLbug, executeQuery, isLbugReady } = await import('../lbug/pool-adapter.js');
|
||||
const { searchFTSFromLbug } = await import('../search/bm25-index.js');
|
||||
|
||||
const repoId = repo.name.toLowerCase();
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Git working tree vs index commit staleness (used by MCP resources, group status, etc.).
|
||||
* Lives in core/ so application code does not depend on the MCP package layer.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export interface StalenessInfo {
|
||||
isStale: boolean;
|
||||
commitsBehind: number;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check how many commits the index is behind HEAD (synchronous; uses git CLI).
|
||||
*/
|
||||
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
|
||||
try {
|
||||
const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
|
||||
const commitsBehind = parseInt(result, 10) || 0;
|
||||
|
||||
if (commitsBehind > 0) {
|
||||
return {
|
||||
isStale: true,
|
||||
commitsBehind,
|
||||
hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isStale: false, commitsBehind: 0 };
|
||||
} catch {
|
||||
return { isStale: false, commitsBehind: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from './types.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
|
||||
const VALID_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'topic', 'lib', 'custom'];
|
||||
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
|
||||
|
||||
const DEFAULT_DETECT = {
|
||||
http: true,
|
||||
grpc: true,
|
||||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MATCHING = {
|
||||
bm25_threshold: 0.7,
|
||||
embedding_threshold: 0.65,
|
||||
max_candidates_per_step: 3,
|
||||
};
|
||||
|
||||
export function parseGroupConfig(yamlContent: string): GroupConfig {
|
||||
const raw = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>;
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
throw new Error('Invalid YAML: expected an object');
|
||||
}
|
||||
|
||||
if (raw.version === undefined) throw new Error('version is required in group.yaml');
|
||||
if (raw.version !== 1) {
|
||||
throw new Error(`Unsupported group.yaml version: ${raw.version}. Expected 1.`);
|
||||
}
|
||||
if (!raw.name || typeof raw.name !== 'string') throw new Error('name is required in group.yaml');
|
||||
if (!raw.repos || typeof raw.repos !== 'object' || Array.isArray(raw.repos)) {
|
||||
throw new Error('repos is required in group.yaml (must be a mapping)');
|
||||
}
|
||||
|
||||
const repos = raw.repos as Record<string, string>;
|
||||
const repoPaths = new Set(Object.keys(repos));
|
||||
|
||||
const rawLinks = (raw.links as unknown[]) || [];
|
||||
const links: GroupManifestLink[] = rawLinks.map((l: unknown, i: number) => {
|
||||
const link = l as Record<string, unknown>;
|
||||
if (!link.from || !repoPaths.has(link.from as string)) {
|
||||
throw new Error(`links[${i}].from "${link.from}" does not match any repo path in group`);
|
||||
}
|
||||
if (!link.to || !repoPaths.has(link.to as string)) {
|
||||
throw new Error(`links[${i}].to "${link.to}" does not match any repo path in group`);
|
||||
}
|
||||
if (!VALID_CONTRACT_TYPES.includes(link.type as ContractType)) {
|
||||
throw new Error(
|
||||
`links[${i}].type "${link.type}" is invalid. Expected: ${VALID_CONTRACT_TYPES.join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (!VALID_ROLES.includes(link.role as ContractRole)) {
|
||||
throw new Error(`links[${i}].role "${link.role}" is invalid. Expected: provider | consumer`);
|
||||
}
|
||||
if (
|
||||
link.contract === undefined ||
|
||||
link.contract === null ||
|
||||
String(link.contract).trim() === ''
|
||||
) {
|
||||
throw new Error(`links[${i}].contract is required`);
|
||||
}
|
||||
return {
|
||||
from: link.from as string,
|
||||
to: link.to as string,
|
||||
type: link.type as ContractType,
|
||||
contract: String(link.contract),
|
||||
role: link.role as ContractRole,
|
||||
};
|
||||
});
|
||||
|
||||
const detect = { ...DEFAULT_DETECT, ...((raw.detect as object) || {}) };
|
||||
const matching = { ...DEFAULT_MATCHING, ...((raw.matching as object) || {}) };
|
||||
const packages = (raw.packages as Record<string, Record<string, string>>) || {};
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
name: raw.name as string,
|
||||
description: (raw.description as string) || '',
|
||||
repos,
|
||||
links,
|
||||
packages,
|
||||
detect,
|
||||
matching,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadGroupConfig(groupDir: string): Promise<GroupConfig> {
|
||||
const fsp = await import('node:fs/promises');
|
||||
const path = await import('node:path');
|
||||
const yamlPath = path.join(groupDir, 'group.yaml');
|
||||
const content = await fsp.readFile(yamlPath, 'utf-8');
|
||||
return parseGroupConfig(content);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ContractType, ExtractedContract, RepoHandle } from './types.js';
|
||||
|
||||
export interface ContractExtractor {
|
||||
type: ContractType;
|
||||
canExtract(repo: RepoHandle): Promise<boolean>;
|
||||
extract(
|
||||
dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]>;
|
||||
}
|
||||
|
||||
export type CypherExecutor = (
|
||||
query: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>[]>;
|
||||
@@ -0,0 +1,357 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
|
||||
function readSafe(repoPath: string, rel: string): string | null {
|
||||
const abs = path.resolve(repoPath, rel);
|
||||
const base = path.resolve(repoPath);
|
||||
const relToBase = path.relative(base, abs);
|
||||
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
|
||||
try {
|
||||
return fs.readFileSync(abs, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function contractId(pkg: string, service: string, method: string): string {
|
||||
const prefix = pkg ? `${pkg}.${service}` : service;
|
||||
return `grpc::${prefix}/${method}`;
|
||||
}
|
||||
|
||||
function serviceOnlyContractId(serviceName: string): string {
|
||||
return `grpc::${serviceName}/*`;
|
||||
}
|
||||
|
||||
function extractServiceBlocks(content: string): Array<{ name: string; body: string }> {
|
||||
const results: Array<{ name: string; body: string }> = [];
|
||||
// v1: brace-depth only — braces inside comments or string literals are not filtered (see spec Fix 2)
|
||||
const headerRe = /service\s+(\w+)\s*\{/g;
|
||||
let headerMatch: RegExpExecArray | null;
|
||||
|
||||
while ((headerMatch = headerRe.exec(content)) !== null) {
|
||||
const serviceName = headerMatch[1];
|
||||
const bodyStart = headerMatch.index + headerMatch[0].length;
|
||||
let depth = 1;
|
||||
let pos = bodyStart;
|
||||
|
||||
while (pos < content.length && depth > 0) {
|
||||
const ch = content[pos];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
|
||||
// If EOF before depth returns to 0, skip incomplete service
|
||||
if (depth !== 0) continue;
|
||||
|
||||
// body is between opening { (consumed by regex) and closing } (pos is one past it)
|
||||
const body = content.slice(bodyStart, pos - 1);
|
||||
results.push({ name: serviceName, body });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function makeContract(
|
||||
cid: string,
|
||||
role: 'provider' | 'consumer',
|
||||
filePath: string,
|
||||
symbolName: string,
|
||||
confidence: number,
|
||||
meta: Record<string, unknown>,
|
||||
): ExtractedContract {
|
||||
return {
|
||||
contractId: cid,
|
||||
type: 'grpc',
|
||||
role,
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName },
|
||||
symbolName,
|
||||
confidence,
|
||||
meta: { ...meta, extractionStrategy: 'source_scan' },
|
||||
};
|
||||
}
|
||||
|
||||
export class GrpcExtractor implements ContractExtractor {
|
||||
type = 'grpc' as const;
|
||||
|
||||
async canExtract(_repo: RepoHandle): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async extract(
|
||||
_dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
// Proto files — definitive provider source
|
||||
const protoFiles = await glob('**/*.proto', {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**'],
|
||||
nodir: true,
|
||||
});
|
||||
for (const rel of protoFiles) {
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (content) out.push(...this.parseProtoFile(content, rel));
|
||||
}
|
||||
|
||||
// Source files — server/client detection
|
||||
const sourceFiles = await glob('**/*.{go,java,py,ts,tsx,js,jsx}', {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
nodir: true,
|
||||
});
|
||||
for (const rel of sourceFiles) {
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
const ext = path.extname(rel).toLowerCase();
|
||||
|
||||
if (ext === '.go') {
|
||||
out.push(...this.scanGoProviders(content, rel));
|
||||
out.push(...this.scanGoConsumers(content, rel));
|
||||
} else if (ext === '.java') {
|
||||
out.push(...this.scanJavaProviders(content, rel));
|
||||
out.push(...this.scanJavaConsumers(content, rel));
|
||||
} else if (ext === '.py') {
|
||||
out.push(...this.scanPythonProviders(content, rel));
|
||||
out.push(...this.scanPythonConsumers(content, rel));
|
||||
} else if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
|
||||
out.push(...this.scanTsProviders(content, rel));
|
||||
}
|
||||
}
|
||||
|
||||
return this.dedupe(out);
|
||||
}
|
||||
|
||||
private parseProtoFile(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
const pkgMatch = content.match(/^package\s+([\w.]+)\s*;/m);
|
||||
const pkg = pkgMatch ? pkgMatch[1] : '';
|
||||
|
||||
for (const { name: serviceName, body } of extractServiceBlocks(content)) {
|
||||
const rpcRe = /rpc\s+(\w+)\s*\(/g;
|
||||
let rpcMatch: RegExpExecArray | null;
|
||||
while ((rpcMatch = rpcRe.exec(body)) !== null) {
|
||||
const methodName = rpcMatch[1];
|
||||
const cid = contractId(pkg, serviceName, methodName);
|
||||
out.push(
|
||||
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.85, {
|
||||
package: pkg,
|
||||
service: serviceName,
|
||||
method: methodName,
|
||||
source: 'proto',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanGoProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
// pb.RegisterXxxServer(
|
||||
const registerRe = /\w+\.Register(\w+)Server\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = registerRe.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'provider',
|
||||
filePath,
|
||||
`Register${serviceName}Server`,
|
||||
0.8,
|
||||
{ service: serviceName, source: 'go_register' },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// pb.UnimplementedXxxServer
|
||||
const unimplRe = /\w+\.Unimplemented(\w+)Server\b/g;
|
||||
while ((m = unimplRe.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'provider',
|
||||
filePath,
|
||||
`Unimplemented${serviceName}Server`,
|
||||
0.8,
|
||||
{ service: serviceName, source: 'go_unimplemented' },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanGoConsumers(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
const re = /\w+\.New(\w+)Client\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'consumer',
|
||||
filePath,
|
||||
`New${serviceName}Client`,
|
||||
0.7,
|
||||
{ service: serviceName, source: 'go_client' },
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanJavaProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
// @GrpcService
|
||||
if (content.includes('@GrpcService')) {
|
||||
const implBaseRe = /extends\s+(\w+)Grpc\.(\w+)ImplBase/;
|
||||
const m = content.match(implBaseRe);
|
||||
if (m) {
|
||||
out.push(
|
||||
makeContract(serviceOnlyContractId(m[1]), 'provider', filePath, m[2], 0.8, {
|
||||
service: m[1],
|
||||
source: 'java_grpc_service',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// Try extracting service name from class name
|
||||
const classRe =
|
||||
/class\s+(\w*?)(?:Grpc)?(?:Service)?\s+extends\s+(\w+)(?:Grpc\.(\w+))?ImplBase/;
|
||||
const cm = content.match(classRe);
|
||||
if (cm) {
|
||||
const svcName = cm[2].replace(/Grpc$/, '');
|
||||
out.push(
|
||||
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, cm[1], 0.8, {
|
||||
service: svcName,
|
||||
source: 'java_grpc_service',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extends XxxImplBase (without @GrpcService)
|
||||
if (!content.includes('@GrpcService')) {
|
||||
const implRe = /extends\s+(\w+?)(?:Grpc\.(\w+))?ImplBase/;
|
||||
const m = content.match(implRe);
|
||||
if (m) {
|
||||
const svcName = m[2] || m[1].replace(/Grpc$/, '');
|
||||
out.push(
|
||||
makeContract(serviceOnlyContractId(svcName), 'provider', filePath, svcName, 0.8, {
|
||||
service: svcName,
|
||||
source: 'java_impl_base',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
// XxxGrpc.newBlockingStub( or XxxGrpc.newStub(
|
||||
const re = /(\w+)Grpc\.new(?:Blocking)?Stub\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'consumer',
|
||||
filePath,
|
||||
`${serviceName}Stub`,
|
||||
0.7,
|
||||
{ service: serviceName, source: 'java_stub' },
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanPythonProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
// add_XxxServicer_to_server(
|
||||
const re = /add_(\w+?)Servicer_to_server\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
out.push(
|
||||
makeContract(
|
||||
serviceOnlyContractId(serviceName),
|
||||
'provider',
|
||||
filePath,
|
||||
`add_${serviceName}Servicer_to_server`,
|
||||
0.8,
|
||||
{ service: serviceName, source: 'python_servicer' },
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanPythonConsumers(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
// XxxStub(
|
||||
const re = /(\w+)Stub\s*\(/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const name = m[1];
|
||||
// Filter out common false positives
|
||||
if (['Mock', 'Test', 'Fake', 'Stub'].includes(name)) continue;
|
||||
out.push(
|
||||
makeContract(serviceOnlyContractId(name), 'consumer', filePath, `${name}Stub`, 0.7, {
|
||||
service: name,
|
||||
source: 'python_stub',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanTsProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
// @GrpcMethod('ServiceName', 'MethodName')
|
||||
const re = /@GrpcMethod\s*\(\s*['"](\w+)['"]\s*,\s*['"](\w+)['"]\s*\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const serviceName = m[1];
|
||||
const methodName = m[2];
|
||||
const cid = contractId('', serviceName, methodName);
|
||||
out.push(
|
||||
makeContract(cid, 'provider', filePath, `${serviceName}.${methodName}`, 0.8, {
|
||||
service: serviceName,
|
||||
method: methodName,
|
||||
source: 'ts_grpc_method',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const c of items) {
|
||||
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
|
||||
const HANDLES_ROUTE_QUERY = `
|
||||
MATCH (handlerFile:File)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
|
||||
RETURN handlerFile.id AS fileId, handlerFile.filePath AS filePath,
|
||||
route.name AS routePath, route.id AS routeId,
|
||||
route.responseKeys AS responseKeys,
|
||||
r.reason AS routeSource`;
|
||||
|
||||
const FETCHES_QUERY = `
|
||||
MATCH (callerFile:File)-[r:CodeRelation {type: 'FETCHES'}]->(route:Route)
|
||||
RETURN callerFile.id AS fileId, callerFile.filePath AS filePath,
|
||||
route.name AS routePath, route.id AS routeId,
|
||||
r.reason AS fetchReason`;
|
||||
|
||||
const CONTAINS_QUERY = `
|
||||
MATCH (file:File {id: $fileId})<-[:CodeRelation {type: 'CONTAINS'}]-(sym)
|
||||
WHERE sym.startLine IS NOT NULL
|
||||
RETURN sym.id AS uid, sym.name AS name, sym.filePath AS filePath, labels(sym) AS labels
|
||||
ORDER BY sym.startLine`;
|
||||
|
||||
export function normalizeHttpPath(p: string): string {
|
||||
let s = p.trim().split('?')[0].toLowerCase().replace(/\/+$/, '');
|
||||
s = s.replace(/:\w+/g, '{param}');
|
||||
s = s.replace(/\{[^}]+\}/g, '{param}');
|
||||
s = s.replace(/\[[^\]]+\]/g, '{param}');
|
||||
return s;
|
||||
}
|
||||
|
||||
function methodFromRouteReason(reason: string): string | null {
|
||||
const r = reason || '';
|
||||
if (/GetMapping|decorator-Get/i.test(r)) return 'GET';
|
||||
if (/PostMapping|decorator-Post/i.test(r)) return 'POST';
|
||||
if (/PutMapping|decorator-Put/i.test(r)) return 'PUT';
|
||||
if (/DeleteMapping|decorator-Delete/i.test(r)) return 'DELETE';
|
||||
if (/PatchMapping|decorator-Patch/i.test(r)) return 'PATCH';
|
||||
return null;
|
||||
}
|
||||
|
||||
function contractIdFor(method: string, pathNorm: string): string {
|
||||
return `http::${method.toUpperCase()}::${pathNorm}`;
|
||||
}
|
||||
|
||||
function readSafe(repoPath: string, rel: string): string | null {
|
||||
const abs = path.resolve(repoPath, rel);
|
||||
const base = path.resolve(repoPath);
|
||||
const relToBase = path.relative(base, abs);
|
||||
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
|
||||
try {
|
||||
return fs.readFileSync(abs, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pickJavaHandlerName(
|
||||
content: string,
|
||||
routePath: string,
|
||||
httpMethod: string,
|
||||
): string | null {
|
||||
const tail = routePath.split('/').filter(Boolean).pop() || '';
|
||||
const mapNames: Record<string, string> = {
|
||||
GET: 'GetMapping',
|
||||
POST: 'PostMapping',
|
||||
PUT: 'PutMapping',
|
||||
DELETE: 'DeleteMapping',
|
||||
PATCH: 'PatchMapping',
|
||||
};
|
||||
const ann = mapNames[httpMethod] || 'GetMapping';
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line.includes(`@${ann}`)) continue;
|
||||
if (!line.includes(`"${tail}"`) && !line.includes(`'${tail}'`) && tail && !line.includes(tail))
|
||||
continue;
|
||||
for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
|
||||
const m = lines[j].match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
|
||||
if (m) return m[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickSymbolUid(
|
||||
rows: Record<string, unknown>[],
|
||||
preferredName: string | null,
|
||||
): { uid: string; name: string; filePath: string } {
|
||||
const norm = (x: unknown) => String(x ?? '');
|
||||
const labeled = rows.filter((r) => {
|
||||
const labels = r.labels ?? r[3];
|
||||
const s = JSON.stringify(labels);
|
||||
return s.includes('Method') || s.includes('Function');
|
||||
});
|
||||
const pool = labeled.length > 0 ? labeled : rows;
|
||||
if (preferredName) {
|
||||
const hit = pool.find((r) => norm(r.name ?? r[1]) === preferredName);
|
||||
if (hit) {
|
||||
return {
|
||||
uid: norm(hit.uid ?? hit[0]),
|
||||
name: norm(hit.name ?? hit[1]),
|
||||
filePath: norm(hit.filePath ?? hit[2]),
|
||||
};
|
||||
}
|
||||
}
|
||||
const first = pool[0] || rows[0];
|
||||
return {
|
||||
uid: norm(first?.uid ?? first?.[0]),
|
||||
name: norm(first?.name ?? first?.[1]),
|
||||
filePath: norm(first?.filePath ?? first?.[2]),
|
||||
};
|
||||
}
|
||||
|
||||
export class HttpRouteExtractor implements ContractExtractor {
|
||||
type = 'http' as const;
|
||||
|
||||
async canExtract(_repo: RepoHandle): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async extract(
|
||||
dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const graphP = dbExecutor != null ? await this.extractProvidersGraph(dbExecutor, repoPath) : [];
|
||||
const providers = graphP.length > 0 ? graphP : await this.extractProvidersSourceScan(repoPath);
|
||||
|
||||
const graphC = dbExecutor != null ? await this.extractConsumersGraph(dbExecutor, repoPath) : [];
|
||||
const consumers = graphC.length > 0 ? graphC : await this.extractConsumersSourceScan(repoPath);
|
||||
|
||||
return [...providers, ...consumers];
|
||||
}
|
||||
|
||||
private async extractProvidersGraph(
|
||||
db: CypherExecutor,
|
||||
repoPath: string,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = await db(HANDLES_ROUTE_QUERY);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const filePath = String(row.filePath ?? '');
|
||||
const routePath = String(row.routePath ?? '');
|
||||
const routeSource = String(row.routeSource ?? row.routeReason ?? '');
|
||||
let method = methodFromRouteReason(routeSource);
|
||||
const content = readSafe(repoPath, filePath);
|
||||
if (!method && content) {
|
||||
method = this.inferMethodFromFileScan(content, routePath, 'provider');
|
||||
}
|
||||
if (!method) method = 'GET';
|
||||
|
||||
const pathNorm = normalizeHttpPath(routePath);
|
||||
const cid = contractIdFor(method, pathNorm);
|
||||
const handlerName =
|
||||
content && routePath ? pickJavaHandlerName(content, routePath, method) : null;
|
||||
|
||||
let symbolUid = '';
|
||||
let symbolName = path.basename(filePath) || 'handler';
|
||||
let symPath = filePath;
|
||||
const fileId = row.fileId ?? row[0];
|
||||
if (fileId) {
|
||||
try {
|
||||
const syms = await db(CONTAINS_QUERY, { fileId });
|
||||
if (syms.length > 0) {
|
||||
const picked = pickSymbolUid(syms, handlerName);
|
||||
symbolUid = picked.uid;
|
||||
symbolName = picked.name;
|
||||
symPath = picked.filePath || filePath;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
out.push({
|
||||
contractId: cid,
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid,
|
||||
symbolRef: { filePath: symPath, name: symbolName },
|
||||
symbolName,
|
||||
confidence: 0.9,
|
||||
meta: {
|
||||
method,
|
||||
path: pathNorm,
|
||||
pathSegments: pathNorm.split('/').filter(Boolean),
|
||||
extractionStrategy: 'graph_assisted',
|
||||
routeSource,
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private inferMethodFromFileScan(
|
||||
content: string,
|
||||
routePath: string,
|
||||
_role: string,
|
||||
): string | null {
|
||||
const tail = routePath.split('/').filter(Boolean).pop() || '';
|
||||
for (const m of ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const) {
|
||||
const mapNames: Record<string, string> = {
|
||||
GET: 'GetMapping',
|
||||
POST: 'PostMapping',
|
||||
PUT: 'PutMapping',
|
||||
DELETE: 'DeleteMapping',
|
||||
PATCH: 'PatchMapping',
|
||||
};
|
||||
if (
|
||||
content.includes(`@${mapNames[m]}`) &&
|
||||
(content.includes(tail) || routePath.includes(tail))
|
||||
) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async extractProvidersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
|
||||
const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
|
||||
nodir: true,
|
||||
});
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const rel of files) {
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
out.push(...this.scanSpringProviders(content, rel));
|
||||
out.push(...this.scanExpressProviders(content, rel));
|
||||
out.push(...this.scanLaravelProviders(content, rel));
|
||||
out.push(...this.scanFastApiProviders(content, rel));
|
||||
}
|
||||
return this.dedupeContracts(out);
|
||||
}
|
||||
|
||||
private dedupeContracts(items: ExtractedContract[]): ExtractedContract[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const c of items) {
|
||||
const k = `${c.contractId}|${c.symbolRef.filePath}|${c.symbolRef.name}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanSpringProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
let classPrefix = '';
|
||||
const classRm = content.match(/@RequestMapping\s*\(\s*"([^"]+)"/);
|
||||
if (classRm) classPrefix = classRm[1].replace(/\/+$/, '');
|
||||
|
||||
const re = /@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*"([^"]+)"/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const method = m[1].toUpperCase();
|
||||
let p = m[2];
|
||||
if (classPrefix) p = `${classPrefix}/${p.replace(/^\//, '')}`;
|
||||
const pathNorm = normalizeHttpPath(p);
|
||||
const sub = content.slice(m.index);
|
||||
const nameM = sub.match(/(?:public|protected|private)\s+[\w<>,\s\[\]]+\s+(\w+)\s*\(/);
|
||||
const name = nameM ? nameM[1] : m[0];
|
||||
out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanExpressProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
const re = /(?:router|app)\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const method = m[1].toUpperCase();
|
||||
const pathNorm = normalizeHttpPath(m[2]);
|
||||
out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanLaravelProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
const re = /Route::(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const method = m[1].toUpperCase();
|
||||
const pathNorm = normalizeHttpPath(m[2]);
|
||||
out.push(this.makeProvider(filePath, method, pathNorm, 'route', 0.8));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private scanFastApiProviders(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
const re = /@app\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const method = m[1].toUpperCase();
|
||||
const pathNorm = normalizeHttpPath(m[2]);
|
||||
out.push(this.makeProvider(filePath, method, pathNorm, 'handler', 0.8));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private makeProvider(
|
||||
filePath: string,
|
||||
method: string,
|
||||
pathNorm: string,
|
||||
name: string,
|
||||
confidence: number,
|
||||
): ExtractedContract {
|
||||
const cid = contractIdFor(method, pathNorm);
|
||||
return {
|
||||
contractId: cid,
|
||||
type: 'http',
|
||||
role: 'provider',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath, name },
|
||||
symbolName: name,
|
||||
confidence,
|
||||
meta: {
|
||||
method,
|
||||
path: pathNorm,
|
||||
pathSegments: pathNorm.split('/').filter(Boolean),
|
||||
extractionStrategy: 'source_scan',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async extractConsumersGraph(
|
||||
db: CypherExecutor,
|
||||
repoPath: string,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = await db(FETCHES_QUERY);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
for (const row of rows) {
|
||||
const filePath = String(row.filePath ?? '');
|
||||
const routePath = String(row.routePath ?? '');
|
||||
const pathNorm = normalizeHttpPath(routePath);
|
||||
let method = 'GET';
|
||||
const content = readSafe(repoPath, filePath);
|
||||
if (content) {
|
||||
const inferred = this.inferFetchMethod(content, pathNorm);
|
||||
if (inferred) method = inferred;
|
||||
}
|
||||
const cid = contractIdFor(method, pathNorm);
|
||||
let symbolUid = '';
|
||||
let symbolName = 'fetch';
|
||||
let symPath = filePath;
|
||||
const fileId = row.fileId ?? row[0];
|
||||
if (fileId) {
|
||||
try {
|
||||
const syms = await db(CONTAINS_QUERY, { fileId });
|
||||
if (syms.length > 0) {
|
||||
const picked = pickSymbolUid(syms, null);
|
||||
symbolUid = picked.uid;
|
||||
symbolName = picked.name;
|
||||
symPath = picked.filePath || filePath;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
contractId: cid,
|
||||
type: 'http',
|
||||
role: 'consumer',
|
||||
symbolUid,
|
||||
symbolRef: { filePath: symPath, name: symbolName },
|
||||
symbolName,
|
||||
confidence: 0.9,
|
||||
meta: {
|
||||
method,
|
||||
path: pathNorm,
|
||||
extractionStrategy: 'graph_assisted',
|
||||
fetchReason: String(row.fetchReason ?? ''),
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private inferFetchMethod(content: string, pathNorm: string): string | null {
|
||||
const esc = pathNorm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const fetchRe = new RegExp(
|
||||
`fetch\\s*\\(\\s*['"\`]([^'"\`]*${esc}[^'"\`]*)['"\`]\\s*,\\s*\\{[^}]*method:\\s*['"](\\w+)['"]`,
|
||||
'i',
|
||||
);
|
||||
const m = content.match(fetchRe);
|
||||
if (m) return m[2].toUpperCase();
|
||||
return null;
|
||||
}
|
||||
|
||||
private async extractConsumersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
|
||||
const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
nodir: true,
|
||||
});
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const rel of files) {
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
out.push(...this.scanFetchConsumers(content, rel));
|
||||
out.push(...this.scanAxiosConsumers(content, rel));
|
||||
}
|
||||
return this.dedupeContracts(out);
|
||||
}
|
||||
|
||||
private scanFetchConsumers(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
const re =
|
||||
/fetch\s*\(\s*['"`]([^'"`]+)['"`](?:\s*,\s*\{[^}]*method:\s*['"](\w+)['"][^}]*\})?\s*\)/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const pathNorm = normalizeHttpPath(this.templateToPattern(m[1]));
|
||||
const method = (m[2] || 'GET').toUpperCase();
|
||||
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private templateToPattern(url: string): string {
|
||||
return url.replace(/\$\{[^}]+\}/g, '{param}');
|
||||
}
|
||||
|
||||
private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const method = m[1].toUpperCase();
|
||||
const pathNorm = normalizeHttpPath(this.templateToPattern(m[2]));
|
||||
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private makeConsumer(
|
||||
filePath: string,
|
||||
method: string,
|
||||
pathNorm: string,
|
||||
confidence: number,
|
||||
): ExtractedContract {
|
||||
return {
|
||||
contractId: contractIdFor(method, pathNorm),
|
||||
type: 'http',
|
||||
role: 'consumer',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath, name: 'fetch' },
|
||||
symbolName: 'fetch',
|
||||
confidence,
|
||||
meta: {
|
||||
method,
|
||||
path: pathNorm,
|
||||
extractionStrategy: 'source_scan',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { glob } from 'glob';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
|
||||
type Broker = 'kafka' | 'rabbitmq' | 'nats';
|
||||
|
||||
function readSafe(repoPath: string, rel: string): string | null {
|
||||
const abs = path.resolve(repoPath, rel);
|
||||
const base = path.resolve(repoPath);
|
||||
const relToBase = path.relative(base, abs);
|
||||
if (relToBase.startsWith('..') || path.isAbsolute(relToBase)) return null;
|
||||
try {
|
||||
return fs.readFileSync(abs, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function makeContract(
|
||||
topicName: string,
|
||||
role: 'provider' | 'consumer',
|
||||
filePath: string,
|
||||
symbolName: string,
|
||||
confidence: number,
|
||||
broker: Broker,
|
||||
): ExtractedContract {
|
||||
return {
|
||||
contractId: `topic::${topicName}`,
|
||||
type: 'topic',
|
||||
role,
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: filePath.replace(/\\/g, '/'), name: symbolName },
|
||||
symbolName,
|
||||
confidence,
|
||||
meta: {
|
||||
broker,
|
||||
topicName,
|
||||
extractionStrategy: 'source_scan',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface PatternDef {
|
||||
regex: RegExp;
|
||||
role: 'provider' | 'consumer';
|
||||
broker: Broker;
|
||||
confidence: number;
|
||||
topicGroup: number;
|
||||
symbolName: string;
|
||||
}
|
||||
|
||||
// --- Kafka patterns ---
|
||||
const KAFKA_PATTERNS: PatternDef[] = [
|
||||
// Java: @KafkaListener(topics = "xxx")
|
||||
{
|
||||
regex: /@KafkaListener\s*\(\s*topics\s*=\s*"([^"]+)"/g,
|
||||
role: 'consumer',
|
||||
broker: 'kafka',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'kafkaListener',
|
||||
},
|
||||
// Java: kafkaTemplate.send("xxx"
|
||||
{
|
||||
regex: /kafkaTemplate\.send\s*\(\s*"([^"]+)"/gi,
|
||||
role: 'provider',
|
||||
broker: 'kafka',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'kafkaTemplate.send',
|
||||
},
|
||||
// Node: producer.send({ topic: 'xxx'
|
||||
{
|
||||
regex: /producer\.send\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g,
|
||||
role: 'provider',
|
||||
broker: 'kafka',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'producer.send',
|
||||
},
|
||||
// Node: consumer.subscribe({ topic: 'xxx'
|
||||
{
|
||||
regex: /consumer\.subscribe\s*\(\s*\{\s*topic:\s*['"]([^'"]+)['"]/g,
|
||||
role: 'consumer',
|
||||
broker: 'kafka',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'consumer.subscribe',
|
||||
},
|
||||
// Go: consumer.ConsumePartition("xxx"
|
||||
{
|
||||
regex: /\.ConsumePartition\s*\(\s*"([^"]+)"/g,
|
||||
role: 'consumer',
|
||||
broker: 'kafka',
|
||||
confidence: 0.7,
|
||||
topicGroup: 1,
|
||||
symbolName: 'ConsumePartition',
|
||||
},
|
||||
// Python: KafkaConsumer('xxx'
|
||||
{
|
||||
regex: /KafkaConsumer\s*\(\s*['"]([^'"]+)['"]/g,
|
||||
role: 'consumer',
|
||||
broker: 'kafka',
|
||||
confidence: 0.7,
|
||||
topicGroup: 1,
|
||||
symbolName: 'KafkaConsumer',
|
||||
},
|
||||
// Python: producer.send('xxx' or producer.produce('xxx'
|
||||
{
|
||||
regex: /producer\.(?:send|produce)\s*\(\s*['"]([^'"]+)['"]/g,
|
||||
role: 'provider',
|
||||
broker: 'kafka',
|
||||
confidence: 0.7,
|
||||
topicGroup: 1,
|
||||
symbolName: 'producer.send',
|
||||
},
|
||||
];
|
||||
|
||||
// --- RabbitMQ patterns ---
|
||||
const RABBITMQ_PATTERNS: PatternDef[] = [
|
||||
// Java: @RabbitListener(queues = "xxx")
|
||||
{
|
||||
regex: /@RabbitListener\s*\(\s*queues\s*=\s*"([^"]+)"/g,
|
||||
role: 'consumer',
|
||||
broker: 'rabbitmq',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'rabbitListener',
|
||||
},
|
||||
// Java: rabbitTemplate.convertAndSend("xxx"
|
||||
{
|
||||
regex: /rabbitTemplate\.convertAndSend\s*\(\s*"([^"]+)"/gi,
|
||||
role: 'provider',
|
||||
broker: 'rabbitmq',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'rabbitTemplate.convertAndSend',
|
||||
},
|
||||
// Node: channel.consume("xxx"
|
||||
{
|
||||
regex: /channel\.consume\s*\(\s*"([^"]+)"/g,
|
||||
role: 'consumer',
|
||||
broker: 'rabbitmq',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'channel.consume',
|
||||
},
|
||||
// Node: channel.publish("xxx"
|
||||
{
|
||||
regex: /channel\.publish\s*\(\s*"([^"]+)"/g,
|
||||
role: 'provider',
|
||||
broker: 'rabbitmq',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'channel.publish',
|
||||
},
|
||||
// Node: channel.sendToQueue("xxx"
|
||||
{
|
||||
regex: /channel\.sendToQueue\s*\(\s*"([^"]+)"/g,
|
||||
role: 'provider',
|
||||
broker: 'rabbitmq',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'channel.sendToQueue',
|
||||
},
|
||||
// Python: channel.basic_consume(queue='xxx'
|
||||
{
|
||||
regex: /channel\.basic_consume\s*\(\s*queue\s*=\s*['"]([^'"]+)['"]/g,
|
||||
role: 'consumer',
|
||||
broker: 'rabbitmq',
|
||||
confidence: 0.7,
|
||||
topicGroup: 1,
|
||||
symbolName: 'basic_consume',
|
||||
},
|
||||
// Python: channel.basic_publish(exchange='xxx'
|
||||
{
|
||||
regex: /channel\.basic_publish\s*\([^)]*exchange\s*=\s*['"]([^'"]+)['"]/g,
|
||||
role: 'provider',
|
||||
broker: 'rabbitmq',
|
||||
confidence: 0.7,
|
||||
topicGroup: 1,
|
||||
symbolName: 'basic_publish',
|
||||
},
|
||||
];
|
||||
|
||||
// --- NATS patterns ---
|
||||
const NATS_PATTERNS: PatternDef[] = [
|
||||
// Go/Node: nc.Subscribe("xxx" or nc.subscribe("xxx"
|
||||
{
|
||||
regex: /nc\.(?:S|s)ubscribe\s*\(\s*"([^"]+)"/g,
|
||||
role: 'consumer',
|
||||
broker: 'nats',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'nc.Subscribe',
|
||||
},
|
||||
// Go/Node: nc.Publish("xxx" or nc.publish("xxx"
|
||||
{
|
||||
regex: /nc\.(?:P|p)ublish\s*\(\s*"([^"]+)"/g,
|
||||
role: 'provider',
|
||||
broker: 'nats',
|
||||
confidence: 0.8,
|
||||
topicGroup: 1,
|
||||
symbolName: 'nc.Publish',
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_PATTERNS: PatternDef[] = [...KAFKA_PATTERNS, ...RABBITMQ_PATTERNS, ...NATS_PATTERNS];
|
||||
|
||||
export class TopicExtractor implements ContractExtractor {
|
||||
type = 'topic' as const;
|
||||
|
||||
async canExtract(_repo: RepoHandle): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async extract(
|
||||
_dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const files = await glob('**/*.{ts,tsx,js,jsx,java,go,py}', {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
nodir: true,
|
||||
});
|
||||
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const rel of files) {
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
out.push(...this.scanFile(content, rel));
|
||||
}
|
||||
|
||||
return this.dedupe(out);
|
||||
}
|
||||
|
||||
private scanFile(content: string, filePath: string): ExtractedContract[] {
|
||||
const out: ExtractedContract[] = [];
|
||||
|
||||
for (const pattern of ALL_PATTERNS) {
|
||||
// Reset regex state for each file
|
||||
const re = new RegExp(pattern.regex.source, pattern.regex.flags);
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
const topicName = m[pattern.topicGroup];
|
||||
if (!topicName) continue;
|
||||
out.push(
|
||||
makeContract(
|
||||
topicName,
|
||||
pattern.role,
|
||||
filePath,
|
||||
pattern.symbolName,
|
||||
pattern.confidence,
|
||||
pattern.broker,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
|
||||
const seen = new Set<string>();
|
||||
const out: ExtractedContract[] = [];
|
||||
for (const c of items) {
|
||||
const k = `${c.contractId}|${c.role}|${c.symbolRef.filePath}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { StoredContract, CrossLink } from './types.js';
|
||||
|
||||
export interface MatchResult {
|
||||
matched: CrossLink[];
|
||||
unmatched: StoredContract[];
|
||||
}
|
||||
|
||||
export function normalizeContractId(id: string): string {
|
||||
const colonIdx = id.indexOf('::');
|
||||
if (colonIdx === -1) return id;
|
||||
|
||||
const type = id.substring(0, colonIdx);
|
||||
const rest = id.substring(colonIdx + 2);
|
||||
|
||||
switch (type) {
|
||||
case 'http': {
|
||||
const parts = rest.split('::');
|
||||
if (parts.length >= 2) {
|
||||
const method = parts[0].toUpperCase();
|
||||
let pathPart = parts.slice(1).join('::');
|
||||
pathPart = pathPart.replace(/\/+$/, '');
|
||||
return `http::${method}::${pathPart}`;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
case 'grpc': {
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx > 0) {
|
||||
const pkg = rest.substring(0, slashIdx).toLowerCase();
|
||||
const method = rest.substring(slashIdx);
|
||||
return `grpc::${pkg}${method}`;
|
||||
}
|
||||
if (slashIdx === 0) {
|
||||
// Malformed "package/method" with leading slash — do not lowercase the whole string
|
||||
// (method segment is case-sensitive per spec).
|
||||
return `grpc::${rest}`;
|
||||
}
|
||||
// No slash: spec is ambiguous (package-only vs full service.method). MVP: lowercase
|
||||
// the whole token; differs from pkg/method split above where RPC method keeps case.
|
||||
return `grpc::${rest.toLowerCase()}`;
|
||||
}
|
||||
case 'topic':
|
||||
return `topic::${rest.trim().toLowerCase()}`;
|
||||
case 'lib':
|
||||
return `lib::${rest.toLowerCase()}`;
|
||||
default:
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
function findMatchingKeys(contractId: string, index: Map<string, StoredContract[]>): string[] {
|
||||
const normalized = normalizeContractId(contractId);
|
||||
if (index.has(normalized)) return [normalized];
|
||||
|
||||
if (normalized.startsWith('http::*::')) {
|
||||
const pathPart = normalized.substring('http::*::'.length);
|
||||
const matches: string[] = [];
|
||||
for (const key of index.keys()) {
|
||||
if (key.startsWith('http::') && key.endsWith(`::${pathPart}`)) {
|
||||
matches.push(key);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function runExactMatch(contracts: StoredContract[]): MatchResult {
|
||||
const providers = contracts.filter((c) => c.role === 'provider');
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
const providerIndex = new Map<string, StoredContract[]>();
|
||||
for (const p of providers) {
|
||||
const key = normalizeContractId(p.contractId);
|
||||
const list = providerIndex.get(key) || [];
|
||||
list.push(p);
|
||||
providerIndex.set(key, list);
|
||||
}
|
||||
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
const matchedProviderIds = new Set<string>();
|
||||
|
||||
for (const consumer of consumers) {
|
||||
const matchingKeys = findMatchingKeys(consumer.contractId, providerIndex);
|
||||
if (matchingKeys.length === 0) continue;
|
||||
|
||||
const allMatchingProviders = matchingKeys.flatMap((k) => providerIndex.get(k) || []);
|
||||
for (const provider of allMatchingProviders) {
|
||||
if (provider.repo === consumer.repo) {
|
||||
if (!provider.service || !consumer.service || provider.service === consumer.service) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
matched.push({
|
||||
from: {
|
||||
repo: consumer.repo,
|
||||
service: consumer.service,
|
||||
symbolUid: consumer.symbolUid,
|
||||
symbolRef: consumer.symbolRef,
|
||||
},
|
||||
to: {
|
||||
repo: provider.repo,
|
||||
service: provider.service,
|
||||
symbolUid: provider.symbolUid,
|
||||
symbolRef: provider.symbolRef,
|
||||
},
|
||||
type: consumer.type,
|
||||
contractId: consumer.contractId,
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
});
|
||||
|
||||
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
|
||||
matchedProviderIds.add(`${provider.repo}::${provider.contractId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const unmatched = contracts.filter((c) => {
|
||||
const id = `${c.repo}::${c.contractId}`;
|
||||
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
|
||||
});
|
||||
|
||||
return { matched, unmatched };
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ServiceBoundary {
|
||||
servicePath: string;
|
||||
serviceName: string;
|
||||
markers: string[];
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
const SERVICE_MARKERS = [
|
||||
'package.json',
|
||||
'go.mod',
|
||||
'Dockerfile',
|
||||
'pom.xml',
|
||||
'build.gradle',
|
||||
'build.gradle.kts',
|
||||
'Cargo.toml',
|
||||
'pyproject.toml',
|
||||
'requirements.txt',
|
||||
'mix.exs',
|
||||
] as const;
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set([
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.js',
|
||||
'.jsx',
|
||||
'.mjs',
|
||||
'.cjs',
|
||||
'.go',
|
||||
'.java',
|
||||
'.kt',
|
||||
'.kts',
|
||||
'.py',
|
||||
'.pyi',
|
||||
'.rs',
|
||||
'.c',
|
||||
'.cpp',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.cs',
|
||||
'.rb',
|
||||
'.php',
|
||||
'.swift',
|
||||
'.dart',
|
||||
'.ex',
|
||||
'.exs',
|
||||
'.erl',
|
||||
'.proto',
|
||||
]);
|
||||
|
||||
const EXCLUDED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'target',
|
||||
'build',
|
||||
'dist',
|
||||
'__pycache__',
|
||||
'.venv',
|
||||
'venv',
|
||||
'.tox',
|
||||
'.mypy_cache',
|
||||
'.gradle',
|
||||
'.mvn',
|
||||
'out',
|
||||
'bin',
|
||||
]);
|
||||
|
||||
export async function detectServiceBoundaries(repoPath: string): Promise<ServiceBoundary[]> {
|
||||
const boundaries: ServiceBoundary[] = [];
|
||||
await walkForBoundaries(repoPath, repoPath, boundaries);
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
async function walkForBoundaries(
|
||||
dir: string,
|
||||
repoRoot: string,
|
||||
results: ServiceBoundary[],
|
||||
): Promise<void> {
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const isRoot = path.resolve(dir) === path.resolve(repoRoot);
|
||||
|
||||
const foundMarkers: string[] = [];
|
||||
let hasSourceFiles = false;
|
||||
const subdirs: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) continue;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
subdirs.push(path.join(dir, entry.name));
|
||||
} else if (entry.isFile()) {
|
||||
if (SERVICE_MARKERS.includes(entry.name as (typeof SERVICE_MARKERS)[number])) {
|
||||
foundMarkers.push(entry.name);
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (SOURCE_EXTENSIONS.has(ext)) {
|
||||
hasSourceFiles = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check subdirectories for source files if not found at this level
|
||||
if (!hasSourceFiles && foundMarkers.length > 0) {
|
||||
hasSourceFiles = await hasSourceFilesInSubdirs(subdirs);
|
||||
}
|
||||
|
||||
if (!isRoot && foundMarkers.length >= 1 && hasSourceFiles) {
|
||||
const relativePath = path.relative(repoRoot, dir).replace(/\\/g, '/');
|
||||
const serviceName = path.basename(dir);
|
||||
const confidence = computeConfidence(foundMarkers.length);
|
||||
|
||||
results.push({
|
||||
servicePath: relativePath,
|
||||
serviceName,
|
||||
markers: foundMarkers,
|
||||
confidence,
|
||||
});
|
||||
}
|
||||
|
||||
// Recurse into subdirectories
|
||||
for (const subdir of subdirs) {
|
||||
await walkForBoundaries(subdir, repoRoot, results);
|
||||
}
|
||||
}
|
||||
|
||||
async function hasSourceFilesInSubdirs(subdirs: string[]): Promise<boolean> {
|
||||
for (const subdir of subdirs) {
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(subdir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) {
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (SOURCE_EXTENSIONS.has(ext)) return true;
|
||||
}
|
||||
if (entry.isDirectory() && !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name)) {
|
||||
const deeper = await hasSourceFilesInSubdirs([path.join(subdir, entry.name)]);
|
||||
if (deeper) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function computeConfidence(markerCount: number): number {
|
||||
if (markerCount >= 3) return 1.0;
|
||||
if (markerCount === 2) return 0.9;
|
||||
return 0.75;
|
||||
}
|
||||
|
||||
export function assignService(filePath: string, boundaries: ServiceBoundary[]): string | undefined {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
|
||||
let bestMatch: ServiceBoundary | undefined;
|
||||
let bestLength = 0;
|
||||
|
||||
for (const boundary of boundaries) {
|
||||
const prefix = boundary.servicePath + '/';
|
||||
if (normalized.startsWith(prefix) && boundary.servicePath.length > bestLength) {
|
||||
bestMatch = boundary;
|
||||
bestLength = boundary.servicePath.length;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.servicePath;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Group orchestration shared by MCP (LocalBackend) and CLI.
|
||||
* DB access is injected via GroupToolPort so this module stays free of LocalBackend private API.
|
||||
*/
|
||||
|
||||
import { checkStaleness } from '../git-staleness.js';
|
||||
import { loadGroupConfig } from './config-parser.js';
|
||||
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
|
||||
import { syncGroup } from './sync.js';
|
||||
|
||||
export interface GroupRepoHandle {
|
||||
id: string;
|
||||
name: string;
|
||||
repoPath: string;
|
||||
storagePath: string;
|
||||
indexedAt?: string;
|
||||
lastCommit?: string;
|
||||
}
|
||||
|
||||
export interface GroupToolPort {
|
||||
resolveRepo(repoParam?: string): Promise<GroupRepoHandle>;
|
||||
impact(
|
||||
repo: GroupRepoHandle,
|
||||
params: {
|
||||
target: string;
|
||||
direction: 'upstream' | 'downstream';
|
||||
maxDepth?: number;
|
||||
relationTypes?: string[];
|
||||
includeTests?: boolean;
|
||||
minConfidence?: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
query(
|
||||
repo: GroupRepoHandle,
|
||||
params: {
|
||||
query: string;
|
||||
task_context?: string;
|
||||
goal?: string;
|
||||
limit?: number;
|
||||
max_symbols?: number;
|
||||
include_content?: boolean;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
impactByUid(
|
||||
repoId: string,
|
||||
uid: string,
|
||||
direction: string,
|
||||
opts: {
|
||||
maxDepth: number;
|
||||
relationTypes: string[];
|
||||
minConfidence: number;
|
||||
includeTests: boolean;
|
||||
},
|
||||
): Promise<unknown | null>;
|
||||
}
|
||||
|
||||
function repoInSubgroup(repoPath: string, subgroup?: string): boolean {
|
||||
if (!subgroup?.trim()) return true;
|
||||
const s = subgroup.replace(/\/+$/, '');
|
||||
return repoPath === s || repoPath.startsWith(`${s}/`);
|
||||
}
|
||||
|
||||
export class GroupService {
|
||||
constructor(private readonly port: GroupToolPort) {}
|
||||
|
||||
async groupList(params: Record<string, unknown>): Promise<unknown> {
|
||||
const name = typeof params.name === 'string' ? params.name.trim() : '';
|
||||
if (!name) {
|
||||
const groups = await listGroups();
|
||||
return { groups };
|
||||
}
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
return {
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
repos: config.repos,
|
||||
links: config.links,
|
||||
};
|
||||
}
|
||||
|
||||
async groupSync(params: Record<string, unknown>): Promise<unknown> {
|
||||
const name = String(params.name ?? '').trim();
|
||||
if (!name) return { error: 'name is required' };
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: Boolean(params.exactOnly),
|
||||
skipEmbeddings: Boolean(params.skipEmbeddings),
|
||||
allowStale: Boolean(params.allowStale),
|
||||
verbose: Boolean(params.verbose),
|
||||
});
|
||||
return {
|
||||
contracts: result.contracts.length,
|
||||
crossLinks: result.crossLinks.length,
|
||||
unmatched: result.unmatched.length,
|
||||
missingRepos: result.missingRepos,
|
||||
};
|
||||
}
|
||||
|
||||
async groupContracts(params: Record<string, unknown>): Promise<unknown> {
|
||||
const name = String(params.name ?? '').trim();
|
||||
if (!name) return { error: 'name is required' };
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
if (!registry) {
|
||||
return { error: `No contracts.json for group "${name}". Run group_sync first.` };
|
||||
}
|
||||
let contracts = registry.contracts;
|
||||
if (params.type) contracts = contracts.filter((c) => c.type === params.type);
|
||||
if (params.repo) contracts = contracts.filter((c) => c.repo === params.repo);
|
||||
if (params.unmatchedOnly) {
|
||||
const matchedIds = new Set(
|
||||
registry.crossLinks.flatMap((l) => [
|
||||
`${l.from.repo}::${l.contractId}`,
|
||||
`${l.to.repo}::${l.contractId}`,
|
||||
]),
|
||||
);
|
||||
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
|
||||
}
|
||||
return { contracts, crossLinks: registry.crossLinks };
|
||||
}
|
||||
|
||||
async groupQuery(params: Record<string, unknown>): Promise<unknown> {
|
||||
const name = String(params.name ?? '').trim();
|
||||
const queryText = String(params.query ?? '').trim();
|
||||
if (!name || !queryText) return { error: 'name and query are required' };
|
||||
|
||||
const limit = typeof params.limit === 'number' && params.limit > 0 ? params.limit : 5;
|
||||
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
const perRepo: Array<{ repo: string; score: number; processes: unknown[] }> = [];
|
||||
for (const [repoPath, registryName] of Object.entries(config.repos)) {
|
||||
if (!repoInSubgroup(repoPath, subgroup)) continue;
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const queryResult = (await this.port.query(repoObj, {
|
||||
query: queryText,
|
||||
limit,
|
||||
max_symbols: 10,
|
||||
include_content: false,
|
||||
})) as { processes?: Array<Record<string, unknown>> };
|
||||
const processes = queryResult.processes || [];
|
||||
const scored = processes.map((p, idx) => ({
|
||||
...p,
|
||||
_rrf_score: 1 / (idx + 1 + 60),
|
||||
_repo: repoPath,
|
||||
}));
|
||||
perRepo.push({ repo: repoPath, score: 0, processes: scored });
|
||||
} catch {
|
||||
perRepo.push({ repo: repoPath, score: 0, processes: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const allProcesses = perRepo.flatMap((r) => r.processes as Array<Record<string, unknown>>);
|
||||
allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number));
|
||||
const topN = allProcesses.slice(0, limit);
|
||||
|
||||
return {
|
||||
group: name,
|
||||
query: queryText,
|
||||
results: topN,
|
||||
per_repo: perRepo.map((r) => ({ repo: r.repo, count: r.processes.length })),
|
||||
};
|
||||
}
|
||||
|
||||
async groupStatus(params: Record<string, unknown>): Promise<unknown> {
|
||||
const name = String(params.name ?? '').trim();
|
||||
if (!name) return { error: 'name is required' };
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
|
||||
const repoStatuses: Record<
|
||||
string,
|
||||
{
|
||||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
missing: boolean;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
const fsp = await import('node:fs/promises');
|
||||
const pathMod = await import('node:path');
|
||||
|
||||
for (const [repoPath, registryName] of Object.entries(config.repos)) {
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const metaPath = pathMod.join(repoObj.storagePath, 'meta.json');
|
||||
const metaRaw = await fsp.readFile(metaPath, 'utf-8').catch(() => '{}');
|
||||
const meta = JSON.parse(metaRaw) as { lastCommit?: string; indexedAt?: string };
|
||||
|
||||
const staleness = meta.lastCommit
|
||||
? checkStaleness(repoObj.repoPath, meta.lastCommit)
|
||||
: { isStale: true, commitsBehind: -1 };
|
||||
|
||||
const snapshot = registry?.repoSnapshots[repoPath];
|
||||
const contractsStale =
|
||||
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
|
||||
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: staleness.isStale,
|
||||
contractsStale: Boolean(contractsStale),
|
||||
missing: false,
|
||||
commitsBehind: staleness.commitsBehind,
|
||||
};
|
||||
} catch {
|
||||
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group: name,
|
||||
lastSync: registry?.generatedAt || null,
|
||||
missingRepos: registry?.missingRepos || [],
|
||||
repos: repoStatuses,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import type { ContractRegistry } from './types.js';
|
||||
|
||||
const CONTRACTS_FILE = 'contracts.json';
|
||||
|
||||
export function getDefaultGitnexusDir(): string {
|
||||
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
}
|
||||
|
||||
export function getGroupsBaseDir(gitnexusDir?: string): string {
|
||||
return path.join(gitnexusDir || getDefaultGitnexusDir(), 'groups');
|
||||
}
|
||||
|
||||
const GROUP_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
||||
|
||||
export function validateGroupName(name: string): void {
|
||||
if (!GROUP_NAME_RE.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid group name "${name}". Names must start with a letter or digit and contain only [a-zA-Z0-9_-].`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getGroupDir(gitnexusDir: string, groupName: string): string {
|
||||
validateGroupName(groupName);
|
||||
return path.join(gitnexusDir, 'groups', groupName);
|
||||
}
|
||||
|
||||
export async function writeContractRegistry(
|
||||
groupDir: string,
|
||||
registry: ContractRegistry,
|
||||
): Promise<void> {
|
||||
const targetPath = path.join(groupDir, CONTRACTS_FILE);
|
||||
const tmpPath = `${targetPath}.tmp.${Date.now()}`;
|
||||
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8');
|
||||
await fsp.rename(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
export async function readContractRegistry(groupDir: string): Promise<ContractRegistry | null> {
|
||||
const filePath = path.join(groupDir, CONTRACTS_FILE);
|
||||
try {
|
||||
const content = await fsp.readFile(filePath, 'utf-8');
|
||||
return JSON.parse(content) as ContractRegistry;
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGroups(gitnexusDir?: string): Promise<string[]> {
|
||||
const groupsDir = getGroupsBaseDir(gitnexusDir);
|
||||
try {
|
||||
const entries = await fsp.readdir(groupsDir, { withFileTypes: true });
|
||||
const names: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const yamlPath = path.join(groupsDir, entry.name, 'group.yaml');
|
||||
if (fs.existsSync(yamlPath)) {
|
||||
names.push(entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGroupDir(
|
||||
gitnexusDir: string,
|
||||
groupName: string,
|
||||
force: boolean = false,
|
||||
): Promise<string> {
|
||||
const groupDir = getGroupDir(gitnexusDir, groupName);
|
||||
if (fs.existsSync(path.join(groupDir, 'group.yaml')) && !force) {
|
||||
throw new Error(`Group "${groupName}" already exists. Use --force to overwrite.`);
|
||||
}
|
||||
await fsp.mkdir(groupDir, { recursive: true });
|
||||
|
||||
const template = `version: 1
|
||||
name: ${groupName}
|
||||
description: ""
|
||||
|
||||
repos: {}
|
||||
|
||||
links: []
|
||||
|
||||
packages: {}
|
||||
|
||||
detect:
|
||||
http: true
|
||||
grpc: true
|
||||
topics: true
|
||||
shared_libs: true
|
||||
embedding_fallback: true
|
||||
|
||||
matching:
|
||||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
`;
|
||||
await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
|
||||
return groupDir;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { initLbug, closeLbug, executeParameterized } from '../lbug/pool-adapter.js';
|
||||
import { readRegistry, type RegistryEntry } from '../../storage/repo-manager.js';
|
||||
import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } from './types.js';
|
||||
import { HttpRouteExtractor } from './extractors/http-route-extractor.js';
|
||||
import { GrpcExtractor } from './extractors/grpc-extractor.js';
|
||||
import { TopicExtractor } from './extractors/topic-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
import { writeContractRegistry } from './storage.js';
|
||||
import type { ContractRegistry } from './types.js';
|
||||
|
||||
export interface SyncOptions {
|
||||
extractorOverride?:
|
||||
| ((repo: RepoHandle) => Promise<StoredContract[]>)
|
||||
| (() => Promise<StoredContract[]>);
|
||||
resolveRepoHandle?: (registryName: string, groupPath: string) => Promise<RepoHandle | null>;
|
||||
skipWrite?: boolean;
|
||||
groupDir?: string;
|
||||
allowStale?: boolean;
|
||||
verbose?: boolean;
|
||||
exactOnly?: boolean;
|
||||
skipEmbeddings?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
contracts: StoredContract[];
|
||||
crossLinks: CrossLink[];
|
||||
unmatched: StoredContract[];
|
||||
missingRepos: string[];
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
}
|
||||
|
||||
export function stableRepoPoolId(entry: RegistryEntry, allEntries: RegistryEntry[]): string {
|
||||
const base = entry.name.toLowerCase();
|
||||
const resolved = path.resolve(entry.path);
|
||||
for (const other of allEntries) {
|
||||
if (other.name.toLowerCase() === base && path.resolve(other.path) !== resolved) {
|
||||
const hash = Buffer.from(entry.path).toString('base64url').slice(0, 6);
|
||||
return `${base}-${hash}`;
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function defaultResolveHandle(allEntries: RegistryEntry[]) {
|
||||
return async (registryName: string, groupPath: string): Promise<RepoHandle | null> => {
|
||||
const e = allEntries.find((en) => en.name === registryName);
|
||||
if (!e) return null;
|
||||
const poolId = stableRepoPoolId(e, allEntries);
|
||||
return {
|
||||
id: poolId,
|
||||
path: groupPath,
|
||||
repoPath: e.path,
|
||||
storagePath: e.storagePath,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promise<SyncResult> {
|
||||
const missingRepos: string[] = [];
|
||||
const repoSnapshots: Record<string, RepoSnapshot> = {};
|
||||
let autoContracts: StoredContract[] = [];
|
||||
let dbExecutors: Map<string, CypherExecutor> | undefined;
|
||||
|
||||
const eo = opts?.extractorOverride;
|
||||
if (eo && eo.length === 0) {
|
||||
autoContracts = await (eo as () => Promise<StoredContract[]>)();
|
||||
} else {
|
||||
const entries = await readRegistry();
|
||||
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
|
||||
const httpEx = new HttpRouteExtractor();
|
||||
const grpcEx = new GrpcExtractor();
|
||||
const topicEx = new TopicExtractor();
|
||||
dbExecutors = new Map<string, CypherExecutor>();
|
||||
const openPoolIds: string[] = [];
|
||||
|
||||
try {
|
||||
for (const [groupPath, regName] of Object.entries(config.repos)) {
|
||||
const handle = await resolve(regName, groupPath);
|
||||
if (!handle) {
|
||||
missingRepos.push(groupPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const poolId = handle.id;
|
||||
const lbugPath = path.join(handle.storagePath, 'lbug');
|
||||
try {
|
||||
await initLbug(poolId, lbugPath);
|
||||
openPoolIds.push(poolId);
|
||||
|
||||
const executor: CypherExecutor = (query, params) =>
|
||||
executeParameterized(poolId, query, params ?? {});
|
||||
|
||||
dbExecutors.set(groupPath, executor);
|
||||
|
||||
const boundaries = await detectServiceBoundaries(handle.repoPath);
|
||||
|
||||
if (config.detect.http) {
|
||||
const extracted = await httpEx.extract(executor, handle.repoPath, handle);
|
||||
for (const c of extracted) {
|
||||
autoContracts.push({
|
||||
...c,
|
||||
repo: groupPath,
|
||||
service: assignService(c.symbolRef.filePath, boundaries),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.detect.grpc) {
|
||||
const extracted = await grpcEx.extract(executor, handle.repoPath, handle);
|
||||
for (const c of extracted) {
|
||||
autoContracts.push({
|
||||
...c,
|
||||
repo: groupPath,
|
||||
service: assignService(c.symbolRef.filePath, boundaries),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.detect.topics) {
|
||||
const extracted = await topicEx.extract(executor, handle.repoPath, handle);
|
||||
for (const c of extracted) {
|
||||
autoContracts.push({
|
||||
...c,
|
||||
repo: groupPath,
|
||||
service: assignService(c.symbolRef.filePath, boundaries),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const metaPath = path.join(handle.storagePath, 'meta.json');
|
||||
try {
|
||||
const raw = await fs.readFile(metaPath, 'utf-8');
|
||||
const m = JSON.parse(raw) as { indexedAt?: string; lastCommit?: string };
|
||||
repoSnapshots[groupPath] = {
|
||||
indexedAt: m.indexedAt || '',
|
||||
lastCommit: m.lastCommit || '',
|
||||
};
|
||||
} catch {
|
||||
const e = entries.find((en) => en.name === regName);
|
||||
repoSnapshots[groupPath] = {
|
||||
indexedAt: e?.indexedAt || '',
|
||||
lastCommit: e?.lastCommit || '',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
missingRepos.push(groupPath);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const id of [...new Set(openPoolIds)]) {
|
||||
await closeLbug(id).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { matched, unmatched } = runExactMatch(autoContracts);
|
||||
const crossLinks: CrossLink[] = matched;
|
||||
const allContracts: StoredContract[] = autoContracts;
|
||||
|
||||
const registry: ContractRegistry = {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoSnapshots,
|
||||
missingRepos,
|
||||
contracts: allContracts,
|
||||
crossLinks,
|
||||
};
|
||||
|
||||
if (opts?.groupDir && !opts.skipWrite) {
|
||||
await writeContractRegistry(opts.groupDir, registry);
|
||||
}
|
||||
|
||||
return {
|
||||
contracts: allContracts,
|
||||
crossLinks,
|
||||
unmatched,
|
||||
missingRepos,
|
||||
repoSnapshots,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
|
||||
export type MatchType = 'exact' | 'manifest' | 'bm25' | 'embedding';
|
||||
export type ContractRole = 'provider' | 'consumer';
|
||||
|
||||
export interface GroupConfig {
|
||||
version: number;
|
||||
name: string;
|
||||
description: string;
|
||||
repos: Record<string, string>;
|
||||
links: GroupManifestLink[];
|
||||
packages: Record<string, Record<string, string>>;
|
||||
detect: DetectConfig;
|
||||
matching: MatchingConfig;
|
||||
}
|
||||
|
||||
export interface GroupManifestLink {
|
||||
from: string;
|
||||
to: string;
|
||||
type: ContractType;
|
||||
contract: string;
|
||||
role: ContractRole;
|
||||
}
|
||||
|
||||
export interface DetectConfig {
|
||||
http: boolean;
|
||||
grpc: boolean;
|
||||
topics: boolean;
|
||||
shared_libs: boolean;
|
||||
embedding_fallback: boolean;
|
||||
}
|
||||
|
||||
export interface MatchingConfig {
|
||||
bm25_threshold: number;
|
||||
embedding_threshold: number;
|
||||
max_candidates_per_step: number;
|
||||
}
|
||||
|
||||
export interface SymbolRef {
|
||||
filePath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ExtractedContract {
|
||||
contractId: string;
|
||||
type: ContractType;
|
||||
role: ContractRole;
|
||||
symbolUid: string;
|
||||
symbolRef: SymbolRef;
|
||||
symbolName: string;
|
||||
confidence: number;
|
||||
meta: Record<string, unknown>;
|
||||
/** Service boundary within a monorepo (relative path from repo root, e.g. "services/auth"). */
|
||||
service?: string;
|
||||
}
|
||||
|
||||
export interface CrossLinkEndpoint {
|
||||
repo: string;
|
||||
/** Service boundary within a monorepo (relative path from repo root). */
|
||||
service?: string;
|
||||
symbolUid: string;
|
||||
symbolRef: SymbolRef;
|
||||
}
|
||||
|
||||
export interface CrossLink {
|
||||
from: CrossLinkEndpoint;
|
||||
to: CrossLinkEndpoint;
|
||||
type: ContractType;
|
||||
contractId: string;
|
||||
matchType: MatchType;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface RepoSnapshot {
|
||||
indexedAt: string;
|
||||
lastCommit: string;
|
||||
}
|
||||
|
||||
export interface ContractRegistry {
|
||||
version: number;
|
||||
generatedAt: string;
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
missingRepos: string[];
|
||||
contracts: StoredContract[];
|
||||
crossLinks: CrossLink[];
|
||||
}
|
||||
|
||||
export interface StoredContract extends ExtractedContract {
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/** Repo within a group (group path + paths; name collision with MCP RepoHandle — import from group/types only). */
|
||||
export interface RepoHandle {
|
||||
id: string;
|
||||
path: string;
|
||||
repoPath: string;
|
||||
storagePath: string;
|
||||
}
|
||||
|
||||
export interface GroupImpactResult {
|
||||
local: unknown;
|
||||
group: string;
|
||||
cross: CrossRepoImpact[];
|
||||
outOfScope: OutOfScopeLink[];
|
||||
truncated: boolean;
|
||||
truncatedRepos: string[];
|
||||
summary: {
|
||||
direct: number;
|
||||
processes_affected: number;
|
||||
modules_affected: number;
|
||||
cross_repo_hits: number;
|
||||
};
|
||||
risk: string;
|
||||
}
|
||||
|
||||
export interface CrossRepoImpact {
|
||||
repo: string;
|
||||
repo_path: string;
|
||||
contract: {
|
||||
id: string;
|
||||
type: ContractType;
|
||||
match_type: MatchType;
|
||||
confidence: number;
|
||||
};
|
||||
by_depth: Record<string, unknown[]>;
|
||||
affected_processes: string[];
|
||||
}
|
||||
|
||||
export interface OutOfScopeLink {
|
||||
from: string;
|
||||
to: string;
|
||||
contractId: string;
|
||||
confidence: number;
|
||||
}
|
||||
@@ -7,13 +7,15 @@ import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js';
|
||||
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
|
||||
import { getProvider } from './languages/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { isVerboseIngestionEnabled } from './utils/verbose.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import {
|
||||
FUNCTION_NODE_TYPES,
|
||||
extractFunctionName,
|
||||
findEnclosingClassId,
|
||||
findEnclosingClassInfo,
|
||||
genericFuncName,
|
||||
inferFunctionLabel,
|
||||
} from './utils/ast-helpers.js';
|
||||
import {
|
||||
countCallArguments,
|
||||
@@ -37,9 +39,11 @@ import type {
|
||||
FileConstructorBindings,
|
||||
} from './workers/parse-worker.js';
|
||||
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
|
||||
import { extractTemplateComponents } from './vue-sfc-extractor.js';
|
||||
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
|
||||
import type { LiteralTypeInferrer } from './type-extractors/types.js';
|
||||
import type { SyntaxNode } from './utils/ast-helpers.js';
|
||||
import { extractParsedCallSite } from './call-sites/extract-language-call-site.js';
|
||||
|
||||
/** Per-file resolved type bindings for exported symbols.
|
||||
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
|
||||
@@ -138,8 +142,10 @@ export function buildExportedTypeMapFromGraph(
|
||||
const filePath = node.properties.filePath as string;
|
||||
const name = node.properties.name as string;
|
||||
if (!name || name.length > MAX_TYPE_NAME_LENGTH) return;
|
||||
// For callable symbols, use returnType; for properties/variables, use declaredType
|
||||
const def = symbolTable.lookupExactFull(filePath, name);
|
||||
// For callable symbols, use returnType; for properties/variables, use declaredType.
|
||||
// Use lookupExactAll + nodeId match to handle same-name methods in different classes.
|
||||
const defs = symbolTable.lookupExactAll(filePath, name);
|
||||
const def = defs.find((d) => d.nodeId === node.id) ?? defs[0];
|
||||
if (!def) return;
|
||||
const typeName = def.returnType ?? def.declaredType;
|
||||
if (!typeName || typeName.length > MAX_TYPE_NAME_LENGTH) return;
|
||||
@@ -229,21 +235,52 @@ const findEnclosingFunction = (
|
||||
|
||||
while (current) {
|
||||
if (FUNCTION_NODE_TYPES.has(current.type)) {
|
||||
const { funcName, label } = extractFunctionName(current);
|
||||
const efnResult = provider.methodExtractor?.extractFunctionName?.(current);
|
||||
const funcName = efnResult?.funcName ?? genericFuncName(current);
|
||||
const label = efnResult?.label ?? inferFunctionLabel(current.type);
|
||||
|
||||
if (funcName) {
|
||||
const resolved = ctx.resolve(funcName, filePath);
|
||||
if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) {
|
||||
// Disambiguate by enclosing class when multiple candidates
|
||||
if (resolved.candidates.length === 1) {
|
||||
return resolved.candidates[0].nodeId;
|
||||
}
|
||||
const classInfo = findEnclosingClassInfo(current, filePath);
|
||||
if (classInfo) {
|
||||
const match = resolved.candidates.find((c) => c.ownerId === classInfo.classId);
|
||||
if (match) return match.nodeId;
|
||||
}
|
||||
if (process.env.NODE_ENV === 'development' && classInfo) {
|
||||
console.warn(
|
||||
`[CallProcessor] Enclosing class '${classInfo.className}' found but no candidate matched — falling back to ${resolved.candidates[0].nodeId}`,
|
||||
);
|
||||
}
|
||||
return resolved.candidates[0].nodeId;
|
||||
}
|
||||
|
||||
// Apply labelOverride so label matches the definition phase (single source of truth).
|
||||
// Fallback: qualify the generated ID to match definition-phase node IDs
|
||||
let finalLabel = label;
|
||||
if (provider.labelOverride) {
|
||||
const override = provider.labelOverride(current, label);
|
||||
if (override !== null) finalLabel = override;
|
||||
}
|
||||
return generateId(finalLabel, `${filePath}:${funcName}`);
|
||||
const classInfo = findEnclosingClassInfo(current, filePath);
|
||||
const qualifiedName = classInfo ? `${classInfo.className}.${funcName}` : funcName;
|
||||
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
|
||||
// Use provider.methodExtractor.extractFromNode — same extractor as definition phase.
|
||||
let arity: number | undefined;
|
||||
if (finalLabel === 'Method' || finalLabel === 'Constructor') {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
const info = language
|
||||
? provider.methodExtractor?.extractFromNode?.(current, { filePath, language })
|
||||
: undefined;
|
||||
if (info) {
|
||||
arity = info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length;
|
||||
}
|
||||
}
|
||||
const arityTag = arity !== undefined ? `#${arity}` : '';
|
||||
return generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,9 +289,21 @@ const findEnclosingFunction = (
|
||||
if (provider.enclosingFunctionFinder) {
|
||||
const customResult = provider.enclosingFunctionFinder(current);
|
||||
if (customResult) {
|
||||
// Try SymbolTable first (same pattern as the FUNCTION_NODE_TYPES branch above).
|
||||
const resolved = ctx.resolve(customResult.funcName, filePath);
|
||||
if (resolved?.tier === 'same-file' && resolved.candidates.length > 0) {
|
||||
if (resolved.candidates.length === 1) {
|
||||
return resolved.candidates[0].nodeId;
|
||||
}
|
||||
const classInfo = findEnclosingClassInfo(current.previousSibling ?? current, filePath);
|
||||
if (classInfo) {
|
||||
const match = resolved.candidates.find((c) => c.ownerId === classInfo.classId);
|
||||
if (match) return match.nodeId;
|
||||
}
|
||||
if (process.env.NODE_ENV === 'development' && classInfo) {
|
||||
console.warn(
|
||||
`[CallProcessor] Enclosing class '${classInfo.className}' found but no candidate matched — falling back to ${resolved.candidates[0].nodeId}`,
|
||||
);
|
||||
}
|
||||
return resolved.candidates[0].nodeId;
|
||||
}
|
||||
let finalLabel = customResult.label;
|
||||
@@ -262,7 +311,24 @@ const findEnclosingFunction = (
|
||||
const override = provider.labelOverride(current.previousSibling!, finalLabel);
|
||||
if (override !== null) finalLabel = override;
|
||||
}
|
||||
return generateId(finalLabel, `${filePath}:${customResult.funcName}`);
|
||||
const classInfo = findEnclosingClassInfo(current.previousSibling ?? current, filePath);
|
||||
const qualifiedName = classInfo
|
||||
? `${classInfo.className}.${customResult.funcName}`
|
||||
: customResult.funcName;
|
||||
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
|
||||
const sigNode = current.previousSibling ?? current;
|
||||
let arity2: number | undefined;
|
||||
if (finalLabel === 'Method' || finalLabel === 'Constructor') {
|
||||
const language = getLanguageFromFilename(filePath);
|
||||
const info = language
|
||||
? provider.methodExtractor?.extractFromNode?.(sigNode, { filePath, language })
|
||||
: undefined;
|
||||
if (info) {
|
||||
arity2 = info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length;
|
||||
}
|
||||
}
|
||||
const arityTag2 = arity2 !== undefined ? `#${arity2}` : '';
|
||||
return generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag2}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,6 +629,7 @@ export const processCalls = async (
|
||||
importedReturnTypes,
|
||||
importedRawReturnTypes,
|
||||
enclosingFunctionFinder: provider?.enclosingFunctionFinder,
|
||||
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
|
||||
});
|
||||
if (typeEnv && exportedTypeMap) {
|
||||
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
|
||||
@@ -643,6 +710,75 @@ export const processCalls = async (
|
||||
|
||||
if (!captureMap['call']) return;
|
||||
|
||||
const callNode = captureMap['call'];
|
||||
const languageSeed = extractParsedCallSite(language, callNode);
|
||||
if (languageSeed) {
|
||||
if (provider.isBuiltInName(languageSeed.calledName)) return;
|
||||
|
||||
const sourceId =
|
||||
findEnclosingFunction(callNode, file.path, ctx, provider) ||
|
||||
generateId('File', file.path);
|
||||
const receiverName =
|
||||
languageSeed.callForm === 'member' ? languageSeed.receiverName : undefined;
|
||||
let receiverTypeName =
|
||||
receiverName && typeEnv ? typeEnv.lookup(receiverName, callNode) : undefined;
|
||||
|
||||
if (
|
||||
receiverName !== undefined &&
|
||||
receiverTypeName === undefined &&
|
||||
languageSeed.callForm === 'member' &&
|
||||
(language === 'java' || language === 'csharp' || language === 'kotlin')
|
||||
) {
|
||||
const c0 = receiverName.charCodeAt(0);
|
||||
if (c0 >= 65 && c0 <= 90) receiverTypeName = receiverName;
|
||||
}
|
||||
|
||||
const resolved = resolveCallTarget(
|
||||
{
|
||||
calledName: languageSeed.calledName,
|
||||
callForm: languageSeed.callForm,
|
||||
...(receiverTypeName !== undefined ? { receiverTypeName } : {}),
|
||||
...(receiverName !== undefined ? { receiverName } : {}),
|
||||
},
|
||||
file.path,
|
||||
ctx,
|
||||
undefined,
|
||||
widenCache,
|
||||
);
|
||||
|
||||
if (!resolved) return;
|
||||
graph.addRelationship({
|
||||
id: generateId('CALLS', `${sourceId}:${languageSeed.calledName}->${resolved.nodeId}`),
|
||||
sourceId,
|
||||
targetId: resolved.nodeId,
|
||||
type: 'CALLS',
|
||||
confidence: resolved.confidence,
|
||||
reason: resolved.reason,
|
||||
});
|
||||
|
||||
if (implementorMap && languageSeed.callForm === 'member' && receiverTypeName) {
|
||||
const implTargets = findInterfaceDispatchTargets(
|
||||
languageSeed.calledName,
|
||||
receiverTypeName,
|
||||
file.path,
|
||||
ctx,
|
||||
implementorMap,
|
||||
resolved.nodeId,
|
||||
);
|
||||
for (const impl of implTargets) {
|
||||
graph.addRelationship({
|
||||
id: generateId('CALLS', `${sourceId}:${languageSeed.calledName}->${impl.nodeId}`),
|
||||
sourceId,
|
||||
targetId: impl.nodeId,
|
||||
type: 'CALLS',
|
||||
confidence: impl.confidence,
|
||||
reason: impl.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nameNode = captureMap['call.name'];
|
||||
if (!nameNode) return;
|
||||
|
||||
@@ -718,7 +854,6 @@ export const processCalls = async (
|
||||
|
||||
if (provider.isBuiltInName(calledName)) return;
|
||||
|
||||
const callNode = captureMap['call'];
|
||||
const callForm = inferCallForm(callNode, nameNode);
|
||||
const receiverName = callForm === 'member' ? extractReceiverName(nameNode) : undefined;
|
||||
let receiverTypeName =
|
||||
@@ -735,7 +870,8 @@ export const processCalls = async (
|
||||
let p = callNode.parent;
|
||||
while (p) {
|
||||
if (FUNCTION_NODE_TYPES.has(p.type)) {
|
||||
const { funcName } = extractFunctionName(p);
|
||||
const funcName =
|
||||
provider.methodExtractor?.extractFunctionName?.(p)?.funcName ?? genericFuncName(p);
|
||||
if (funcName) {
|
||||
scope = `${funcName}@${p.startIndex}`;
|
||||
break;
|
||||
@@ -893,6 +1029,41 @@ export const processCalls = async (
|
||||
}
|
||||
});
|
||||
|
||||
// Vue: emit CALLS edges for PascalCase components used in <template>.
|
||||
// Template components are default-imported (not named), so we match the
|
||||
// component name against imported .vue file basenames via the import map.
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const templateComponents = extractTemplateComponents(file.content);
|
||||
if (templateComponents.length > 0) {
|
||||
const fileId = generateId('File', file.path);
|
||||
const importedFiles = ctx.importMap.get(file.path);
|
||||
if (importedFiles) {
|
||||
for (const componentName of templateComponents) {
|
||||
for (const importedPath of importedFiles) {
|
||||
if (!importedPath.endsWith('.vue')) continue;
|
||||
const basename = importedPath.slice(
|
||||
importedPath.lastIndexOf('/') + 1,
|
||||
importedPath.lastIndexOf('.'),
|
||||
);
|
||||
if (basename !== componentName) continue;
|
||||
const targetFileId = generateId('File', importedPath);
|
||||
if (graph.getNode(targetFileId)) {
|
||||
graph.addRelationship({
|
||||
id: generateId('CALLS', `${fileId}:${componentName}->${targetFileId}`),
|
||||
sourceId: fileId,
|
||||
targetId: targetFileId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.9,
|
||||
reason: 'vue-template-component',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearCache();
|
||||
}
|
||||
|
||||
@@ -1242,10 +1413,18 @@ const resolveCallTarget = (
|
||||
/** Extract the function name from a scope key ("funcName@startIndex" → "funcName"). */
|
||||
const extractFuncNameFromScope = (scope: string): string => scope.slice(0, scope.indexOf('@'));
|
||||
|
||||
/** Extract the trailing function name from a sourceId ("Function:filepath:funcName" → "funcName"). */
|
||||
/** Extract the bare function name from a sourceId.
|
||||
* Handles both unqualified ("Function:filepath:funcName" → "funcName")
|
||||
* and qualified ("Function:filepath:ClassName.funcName" → "funcName").
|
||||
* Strips any trailing #<arity> suffix from Method/Constructor IDs. */
|
||||
const extractFuncNameFromSourceId = (sourceId: string): string => {
|
||||
const lastColon = sourceId.lastIndexOf(':');
|
||||
return lastColon >= 0 ? sourceId.slice(lastColon + 1) : '';
|
||||
const segment = lastColon >= 0 ? sourceId.slice(lastColon + 1) : '';
|
||||
const dotIdx = segment.lastIndexOf('.');
|
||||
const raw = dotIdx >= 0 ? segment.slice(dotIdx + 1) : segment;
|
||||
// Strip #<arity> suffix (e.g. "save#2" → "save")
|
||||
const hashIdx = raw.indexOf('#');
|
||||
return hashIdx >= 0 ? raw.slice(0, hashIdx) : raw;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1590,7 +1769,38 @@ export const processCallsFromExtracted = async (
|
||||
widenCache,
|
||||
effectiveCall.argTypes,
|
||||
);
|
||||
if (!resolved) continue;
|
||||
if (!resolved) {
|
||||
// Vue template component fallback: match calledName against imported .vue basenames
|
||||
if (effectiveCall.filePath.endsWith('.vue') && effectiveCall.sourceId.startsWith('File:')) {
|
||||
const importedFiles = ctx.importMap.get(effectiveCall.filePath);
|
||||
if (importedFiles) {
|
||||
for (const importedPath of importedFiles) {
|
||||
if (!importedPath.endsWith('.vue')) continue;
|
||||
const basename = importedPath.slice(
|
||||
importedPath.lastIndexOf('/') + 1,
|
||||
importedPath.lastIndexOf('.'),
|
||||
);
|
||||
if (basename !== effectiveCall.calledName) continue;
|
||||
const targetFileId = generateId('File', importedPath);
|
||||
if (graph.getNode(targetFileId)) {
|
||||
graph.addRelationship({
|
||||
id: generateId(
|
||||
'CALLS',
|
||||
`${effectiveCall.sourceId}:${effectiveCall.calledName}->${targetFileId}`,
|
||||
),
|
||||
sourceId: effectiveCall.sourceId,
|
||||
targetId: targetFileId,
|
||||
type: 'CALLS',
|
||||
confidence: 0.9,
|
||||
reason: 'vue-template-component',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const relId = generateId(
|
||||
'CALLS',
|
||||
|
||||
@@ -226,6 +226,7 @@ export const ENTRY_POINT_PATTERNS = {
|
||||
/^onEvent$/, // BLoC event handler
|
||||
/^mapEventToState$/, // Legacy BLoC pattern
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript queries — entry points handled via TS patterns
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no tree-sitter entry points
|
||||
} satisfies Record<SupportedLanguages, RegExp[]>;
|
||||
|
||||
|
||||
@@ -15,12 +15,17 @@ import type { FieldVisibility } from '../../field-types.js';
|
||||
|
||||
/**
|
||||
* Check whether any child of `node` (named or unnamed) has .text matching
|
||||
* one of the given `keywords`.
|
||||
* the given `keyword`.
|
||||
*
|
||||
* Skips the `name` field child to avoid false positives when a method is
|
||||
* named after a contextual keyword (e.g. `abstract()` in TypeScript).
|
||||
*/
|
||||
export function hasKeyword(node: SyntaxNode, keyword: string): boolean {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child && child.text.trim() === keyword) return true;
|
||||
if (!child || child === nameNode) continue;
|
||||
if (child.text.trim() === keyword) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -46,6 +51,7 @@ export function hasModifier(node: SyntaxNode, modifierType: string, keyword: str
|
||||
/**
|
||||
* Return the first matching visibility keyword found either as a direct keyword
|
||||
* child or inside a modifier wrapper node.
|
||||
* Skips the `name` field child (same rationale as hasKeyword).
|
||||
*/
|
||||
export function findVisibility(
|
||||
node: SyntaxNode,
|
||||
@@ -53,10 +59,12 @@ export function findVisibility(
|
||||
defaultVis: FieldVisibility,
|
||||
modifierNodeType?: string,
|
||||
): FieldVisibility {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
// Direct keyword children
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
const text = child?.text.trim() as FieldVisibility | undefined;
|
||||
if (!child || child === nameNode) continue;
|
||||
const text = child.text.trim() as FieldVisibility | undefined;
|
||||
if (text && (keywords as ReadonlySet<string>).has(text)) return text;
|
||||
}
|
||||
// Modifier wrapper
|
||||
|
||||
@@ -891,6 +891,7 @@ export const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE = {
|
||||
patterns: FRAMEWORK_AST_PATTERNS.riverpod,
|
||||
},
|
||||
],
|
||||
[SupportedLanguages.Vue]: [], // Vue uses TypeScript AST framework detection
|
||||
[SupportedLanguages.Cobol]: [], // Standalone regex processor — no AST framework patterns
|
||||
} satisfies Record<SupportedLanguages, AstFrameworkPatternConfig[]>;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const EXTENSIONS = [
|
||||
'.ts',
|
||||
'.jsx',
|
||||
'.js',
|
||||
'.vue',
|
||||
'/index.tsx',
|
||||
'/index.ts',
|
||||
'/index.jsx',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Vue import resolver — delegates to TypeScript's standard resolver.
|
||||
*
|
||||
* Vue <script> blocks use the same import syntax as TypeScript (including
|
||||
* tsconfig path aliases like `@/`), so no custom resolution logic is needed.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { resolveStandard } from './standard.js';
|
||||
import type { ImportResolverFn } from './types.js';
|
||||
|
||||
export const resolveVueImport: ImportResolverFn = (raw, fp, ctx) =>
|
||||
resolveStandard(raw, fp, ctx, SupportedLanguages.TypeScript);
|
||||
@@ -15,13 +15,27 @@ import { cCppExportChecker } from '../export-detection.js';
|
||||
import { resolveCImport, resolveCppImport } from '../import-resolvers/standard.js';
|
||||
import { C_QUERIES, CPP_QUERIES } from '../tree-sitter-queries.js';
|
||||
|
||||
import { isCppInsideClassOrStruct } from '../utils/ast-helpers.js';
|
||||
/**
|
||||
* Node types for standard function declarations that need C/C++ declarator handling.
|
||||
* Used by cCppExtractFunctionName to determine how to extract the function name.
|
||||
*/
|
||||
const FUNCTION_DECLARATION_TYPES = new Set([
|
||||
'function_declaration',
|
||||
'function_definition',
|
||||
'async_function_declaration',
|
||||
'generator_function_declaration',
|
||||
'function_item',
|
||||
]);
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import {
|
||||
cConfig as cFieldConfig,
|
||||
cppConfig as cppFieldConfig,
|
||||
} from '../field-extractors/configs/c-cpp.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { cMethodConfig, cppMethodConfig } from '../method-extractors/configs/c-cpp.js';
|
||||
|
||||
const C_BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'printf',
|
||||
@@ -130,6 +144,154 @@ const C_BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'put',
|
||||
]);
|
||||
|
||||
/**
|
||||
* C/C++ function name extraction — unwraps pointer_declarator / reference_declarator /
|
||||
* function_declarator / qualified_identifier chains to find the actual function name.
|
||||
* Handles field_identifier (method inside class body) and parenthesized_declarator.
|
||||
*/
|
||||
const cCppExtractFunctionName = (
|
||||
node: SyntaxNode,
|
||||
): { funcName: string | null; label: NodeLabel } | null => {
|
||||
if (!FUNCTION_DECLARATION_TYPES.has(node.type)) return null;
|
||||
|
||||
let funcName: string | null = null;
|
||||
let label: NodeLabel = 'Function';
|
||||
|
||||
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
|
||||
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
|
||||
let declarator = node.childForFieldName?.('declarator');
|
||||
if (!declarator) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_declarator') {
|
||||
declarator = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (
|
||||
declarator &&
|
||||
(declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')
|
||||
) {
|
||||
let nextDeclarator = declarator.childForFieldName?.('declarator');
|
||||
if (!nextDeclarator) {
|
||||
for (let i = 0; i < declarator.childCount; i++) {
|
||||
const c = declarator.child(i);
|
||||
if (
|
||||
c?.type === 'function_declarator' ||
|
||||
c?.type === 'pointer_declarator' ||
|
||||
c?.type === 'reference_declarator'
|
||||
) {
|
||||
nextDeclarator = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
declarator = nextDeclarator;
|
||||
}
|
||||
if (declarator) {
|
||||
let innerDeclarator = declarator.childForFieldName?.('declarator');
|
||||
if (!innerDeclarator) {
|
||||
for (let i = 0; i < declarator.childCount; i++) {
|
||||
const c = declarator.child(i);
|
||||
if (
|
||||
c?.type === 'qualified_identifier' ||
|
||||
c?.type === 'identifier' ||
|
||||
c?.type === 'field_identifier' ||
|
||||
c?.type === 'parenthesized_declarator'
|
||||
) {
|
||||
innerDeclarator = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (innerDeclarator?.type === 'qualified_identifier') {
|
||||
let nameNode = innerDeclarator.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nameNode?.text) {
|
||||
funcName = nameNode.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (
|
||||
innerDeclarator?.type === 'identifier' ||
|
||||
innerDeclarator?.type === 'field_identifier'
|
||||
) {
|
||||
// field_identifier is used for method names inside C++ class bodies
|
||||
funcName = innerDeclarator.text;
|
||||
if (innerDeclarator.type === 'field_identifier') label = 'Method';
|
||||
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
|
||||
let nestedId: SyntaxNode | null = null;
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') {
|
||||
nestedId = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nestedId?.type === 'qualified_identifier') {
|
||||
let nameNode = nestedId.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < nestedId.childCount; i++) {
|
||||
const c = nestedId.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nameNode?.text) {
|
||||
funcName = nameNode.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (nestedId?.type === 'identifier') {
|
||||
funcName = nestedId.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other node types in FUNCTION_DECLARATION_TYPES (e.g. function_item for Rust in C++ tree)
|
||||
if (!funcName) {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (
|
||||
c?.type === 'identifier' ||
|
||||
c?.type === 'property_identifier' ||
|
||||
c?.type === 'simple_identifier'
|
||||
) {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text ?? null;
|
||||
}
|
||||
|
||||
return { funcName, label };
|
||||
};
|
||||
|
||||
/** Check if a C/C++ function_definition is inside a class or struct body.
|
||||
* Used by cppLabelOverride to skip duplicate function captures
|
||||
* that are already covered by definition.method queries. */
|
||||
function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean {
|
||||
let ancestor: SyntaxNode | null = functionNode?.parent ?? null;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Label override shared by C and C++: skip function_definition captures inside class/struct
|
||||
* bodies (they're duplicates of definition.method captures). */
|
||||
const cppLabelOverride: NonNullable<LanguageProvider['labelOverride']> = (
|
||||
@@ -149,6 +311,10 @@ export const cProvider = defineLanguage({
|
||||
importResolver: resolveCImport,
|
||||
importSemantics: 'wildcard',
|
||||
fieldExtractor: createFieldExtractor(cFieldConfig),
|
||||
methodExtractor: createMethodExtractor({
|
||||
...cMethodConfig,
|
||||
extractFunctionName: cCppExtractFunctionName,
|
||||
}),
|
||||
labelOverride: cppLabelOverride,
|
||||
builtInNames: C_BUILT_INS,
|
||||
});
|
||||
@@ -163,6 +329,10 @@ export const cppProvider = defineLanguage({
|
||||
importSemantics: 'wildcard',
|
||||
mroStrategy: 'leftmost-base',
|
||||
fieldExtractor: createFieldExtractor(cppFieldConfig),
|
||||
methodExtractor: createMethodExtractor({
|
||||
...cppMethodConfig,
|
||||
extractFunctionName: cCppExtractFunctionName,
|
||||
}),
|
||||
labelOverride: cppLabelOverride,
|
||||
builtInNames: C_BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { FUNCTION_NODE_TYPES, extractFunctionName } from '../utils/ast-helpers.js';
|
||||
import { FUNCTION_NODE_TYPES } from '../utils/ast-helpers.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import { typeConfig as dartConfig } from '../type-extractors/dart.js';
|
||||
@@ -21,6 +21,8 @@ import { resolveDartImport } from '../import-resolvers/dart.js';
|
||||
import { DART_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { dartMethodConfig } from '../method-extractors/configs/dart.js';
|
||||
|
||||
/**
|
||||
* Resolve the enclosing function from a `function_body` node by looking at its
|
||||
@@ -28,8 +30,8 @@ import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.
|
||||
* function_body are siblings under program or class_body, unlike most languages
|
||||
* where the function declaration wraps both.
|
||||
*
|
||||
* Delegates name extraction to the shared `extractFunctionName` which already
|
||||
* handles Dart's function_signature and method_signature node types.
|
||||
* Extracts the function name inline — Dart uses function_signature and
|
||||
* method_signature (which wraps function_signature) as its FUNCTION_NODE_TYPES.
|
||||
*/
|
||||
const dartEnclosingFunctionFinder = (
|
||||
node: SyntaxNode,
|
||||
@@ -37,7 +39,21 @@ const dartEnclosingFunctionFinder = (
|
||||
if (node.type !== 'function_body') return null;
|
||||
const prev = node.previousSibling;
|
||||
if (!prev || !FUNCTION_NODE_TYPES.has(prev.type)) return null;
|
||||
const { funcName, label } = extractFunctionName(prev);
|
||||
|
||||
// method_signature wraps function_signature — unwrap to reach the name
|
||||
let target = prev;
|
||||
let label: NodeLabel = 'Function';
|
||||
if (prev.type === 'method_signature') {
|
||||
label = 'Method';
|
||||
for (let i = 0; i < prev.childCount; i++) {
|
||||
const c = prev.child(i);
|
||||
if (c?.type === 'function_signature') {
|
||||
target = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const funcName = target.childForFieldName?.('name')?.text ?? null;
|
||||
return funcName ? { funcName, label } : null;
|
||||
};
|
||||
|
||||
@@ -75,6 +91,7 @@ export const dartProvider = defineLanguage({
|
||||
importResolver: resolveDartImport,
|
||||
importSemantics: 'wildcard',
|
||||
fieldExtractor: createFieldExtractor(dartFieldConfig),
|
||||
methodExtractor: createMethodExtractor(dartMethodConfig),
|
||||
enclosingFunctionFinder: dartEnclosingFunctionFinder,
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ import { resolveGoImport } from '../import-resolvers/go.js';
|
||||
import { GO_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { goConfig as goFieldConfig } from '../field-extractors/configs/go.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { goMethodConfig } from '../method-extractors/configs/go.js';
|
||||
|
||||
export const goProvider = defineLanguage({
|
||||
id: SupportedLanguages.Go,
|
||||
@@ -27,4 +29,5 @@ export const goProvider = defineLanguage({
|
||||
importResolver: resolveGoImport,
|
||||
importSemantics: 'wildcard',
|
||||
fieldExtractor: createFieldExtractor(goFieldConfig),
|
||||
methodExtractor: createMethodExtractor(goMethodConfig),
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { phpProvider } from './php.js';
|
||||
import { rubyProvider } from './ruby.js';
|
||||
import { swiftProvider } from './swift.js';
|
||||
import { dartProvider } from './dart.js';
|
||||
import { vueProvider } from './vue.js';
|
||||
import { cobolProvider } from './cobol.js';
|
||||
|
||||
export const providers = {
|
||||
@@ -40,6 +41,7 @@ export const providers = {
|
||||
[SupportedLanguages.Ruby]: rubyProvider,
|
||||
[SupportedLanguages.Swift]: swiftProvider,
|
||||
[SupportedLanguages.Dart]: dartProvider,
|
||||
[SupportedLanguages.Vue]: vueProvider,
|
||||
[SupportedLanguages.Cobol]: cobolProvider,
|
||||
} satisfies Record<SupportedLanguages, LanguageProvider>;
|
||||
|
||||
|
||||
@@ -15,12 +15,26 @@ import { resolveKotlinImport } from '../import-resolvers/jvm.js';
|
||||
import { extractKotlinNamedBindings } from '../named-bindings/kotlin.js';
|
||||
import { appendKotlinWildcard } from '../import-resolvers/jvm.js';
|
||||
import { KOTLIN_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { isKotlinClassMethod } from '../utils/ast-helpers.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { kotlinConfig } from '../field-extractors/configs/jvm.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js';
|
||||
|
||||
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
|
||||
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
|
||||
* Returns true when the captured definition node has a class_body ancestor. */
|
||||
function isKotlinClassMethod(
|
||||
captureNode: { parent?: SyntaxNode | null } | null | undefined,
|
||||
): boolean {
|
||||
let ancestor = captureNode?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_body') return true;
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'println',
|
||||
'print',
|
||||
|
||||
@@ -17,6 +17,8 @@ import { findDescendant, extractStringContent, type SyntaxNode } from '../utils/
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { phpConfig as phpFieldConfig } from '../field-extractors/configs/php.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { phpMethodConfig } from '../method-extractors/configs/php.js';
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'echo',
|
||||
@@ -231,6 +233,7 @@ export const phpProvider = defineLanguage({
|
||||
importResolver: resolvePhpImport,
|
||||
namedBindingExtractor: extractPhpNamedBindings,
|
||||
fieldExtractor: createFieldExtractor(phpFieldConfig),
|
||||
methodExtractor: createMethodExtractor(phpMethodConfig),
|
||||
descriptionExtractor: phpDescriptionExtractor,
|
||||
isRouteFile: isPhpRouteFile,
|
||||
builtInNames: BUILT_INS,
|
||||
|
||||
@@ -19,6 +19,8 @@ import { extractPythonNamedBindings } from '../named-bindings/python.js';
|
||||
import { PYTHON_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { pythonConfig as pythonFieldConfig } from '../field-extractors/configs/python.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { pythonMethodConfig } from '../method-extractors/configs/python.js';
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'print',
|
||||
@@ -61,5 +63,6 @@ export const pythonProvider = defineLanguage({
|
||||
importSemantics: 'namespace',
|
||||
mroStrategy: 'c3',
|
||||
fieldExtractor: createFieldExtractor(pythonFieldConfig),
|
||||
methodExtractor: createMethodExtractor(pythonMethodConfig),
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { typeConfig as rubyConfig } from '../type-extractors/ruby.js';
|
||||
import { routeRubyCall } from '../call-routing.js';
|
||||
import { rubyExportChecker } from '../export-detection.js';
|
||||
@@ -16,6 +18,27 @@ import { resolveRubyImport } from '../import-resolvers/ruby.js';
|
||||
import { RUBY_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { rubyConfig as rubyFieldConfig } from '../field-extractors/configs/ruby.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { rubyMethodConfig } from '../method-extractors/configs/ruby.js';
|
||||
|
||||
/** Ruby method/singleton_method: extract name from 'name' field, label as Method. */
|
||||
const rubyExtractFunctionName = (
|
||||
node: SyntaxNode,
|
||||
): { funcName: string | null; label: NodeLabel } | null => {
|
||||
if (node.type !== 'method' && node.type !== 'singleton_method') return null;
|
||||
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { funcName: nameNode?.text ?? null, label: 'Method' };
|
||||
};
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'puts',
|
||||
@@ -85,5 +108,9 @@ export const rubyProvider = defineLanguage({
|
||||
callRouter: routeRubyCall,
|
||||
importSemantics: 'wildcard',
|
||||
fieldExtractor: createFieldExtractor(rubyFieldConfig),
|
||||
methodExtractor: createMethodExtractor({
|
||||
...rubyMethodConfig,
|
||||
extractFunctionName: rubyExtractFunctionName,
|
||||
}),
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { typeConfig as rustConfig } from '../type-extractors/rust.js';
|
||||
import { rustExportChecker } from '../export-detection.js';
|
||||
import { resolveRustImport } from '../import-resolvers/rust.js';
|
||||
@@ -19,6 +21,37 @@ import { extractRustNamedBindings } from '../named-bindings/rust.js';
|
||||
import { RUST_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { rustConfig as rustFieldConfig } from '../field-extractors/configs/rust.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { rustMethodConfig } from '../method-extractors/configs/rust.js';
|
||||
|
||||
/** Rust impl_item: find the function_item child and extract its name as a Method. */
|
||||
const rustExtractFunctionName = (
|
||||
node: SyntaxNode,
|
||||
): { funcName: string | null; label: NodeLabel } | null => {
|
||||
if (node.type !== 'impl_item') return null;
|
||||
|
||||
let funcItem: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_item') {
|
||||
funcItem = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!funcItem) return null;
|
||||
|
||||
let nameNode = funcItem.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < funcItem.childCount; i++) {
|
||||
const c = funcItem.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { funcName: nameNode?.text ?? null, label: 'Method' };
|
||||
};
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'unwrap',
|
||||
@@ -87,5 +120,9 @@ export const rustProvider = defineLanguage({
|
||||
namedBindingExtractor: extractRustNamedBindings,
|
||||
mroStrategy: 'qualified-syntax',
|
||||
fieldExtractor: createFieldExtractor(rustFieldConfig),
|
||||
methodExtractor: createMethodExtractor({
|
||||
...rustMethodConfig,
|
||||
extractFunctionName: rustExtractFunctionName,
|
||||
}),
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -11,14 +11,18 @@
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import { typeConfig as swiftConfig } from '../type-extractors/swift.js';
|
||||
import { swiftExportChecker } from '../export-detection.js';
|
||||
import { resolveSwiftImport } from '../import-resolvers/swift.js';
|
||||
import { SWIFT_QUERIES } from '../tree-sitter-queries.js';
|
||||
import type { SwiftPackageConfig } from '../language-config.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { swiftConfig as swiftFieldConfig } from '../field-extractors/configs/swift.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { swiftMethodConfig } from '../method-extractors/configs/swift.js';
|
||||
|
||||
/**
|
||||
* Group Swift files by SPM target for implicit module visibility.
|
||||
@@ -107,6 +111,15 @@ function wireSwiftImplicitImports(
|
||||
}
|
||||
}
|
||||
|
||||
/** Swift init/deinit declarations have special names and Constructor label. */
|
||||
const swiftExtractFunctionName = (
|
||||
node: SyntaxNode,
|
||||
): { funcName: string | null; label: NodeLabel } | null => {
|
||||
if (node.type === 'init_declaration') return { funcName: 'init', label: 'Constructor' };
|
||||
if (node.type === 'deinit_declaration') return { funcName: 'deinit', label: 'Constructor' };
|
||||
return null; // fall through to generic
|
||||
};
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'print',
|
||||
'debugPrint',
|
||||
@@ -227,6 +240,10 @@ export const swiftProvider = defineLanguage({
|
||||
importSemantics: 'wildcard',
|
||||
heritageDefaultEdge: 'IMPLEMENTS',
|
||||
fieldExtractor: createFieldExtractor(swiftFieldConfig),
|
||||
methodExtractor: createMethodExtractor({
|
||||
...swiftMethodConfig,
|
||||
extractFunctionName: swiftExtractFunctionName,
|
||||
}),
|
||||
implicitImportWirer: wireSwiftImplicitImports,
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import type { SyntaxNode } from '../utils/ast-helpers.js';
|
||||
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
|
||||
import { tsExportChecker } from '../export-detection.js';
|
||||
import { resolveTypescriptImport, resolveJavascriptImport } from '../import-resolvers/standard.js';
|
||||
@@ -17,8 +19,38 @@ import { TYPESCRIPT_QUERIES, JAVASCRIPT_QUERIES } from '../tree-sitter-queries.j
|
||||
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { javascriptConfig } from '../field-extractors/configs/typescript-javascript.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import {
|
||||
typescriptMethodConfig,
|
||||
javascriptMethodConfig,
|
||||
} from '../method-extractors/configs/typescript-javascript.js';
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
/**
|
||||
* TypeScript/JavaScript: arrow_function and function_expression get their name
|
||||
* from the parent variable_declarator (e.g. `const foo = () => {}`).
|
||||
*/
|
||||
const tsExtractFunctionName = (
|
||||
node: SyntaxNode,
|
||||
): { funcName: string | null; label: NodeLabel } | null => {
|
||||
if (node.type !== 'arrow_function' && node.type !== 'function_expression') return null;
|
||||
|
||||
const parent = node.parent;
|
||||
if (parent?.type !== 'variable_declarator') return null;
|
||||
|
||||
let nameNode = parent.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const c = parent.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { funcName: nameNode?.text ?? null, label: 'Function' };
|
||||
};
|
||||
|
||||
export const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'console',
|
||||
'log',
|
||||
'warn',
|
||||
@@ -124,6 +156,10 @@ export const typescriptProvider = defineLanguage({
|
||||
importResolver: resolveTypescriptImport,
|
||||
namedBindingExtractor: extractTsNamedBindings,
|
||||
fieldExtractor: typescriptFieldExtractor,
|
||||
methodExtractor: createMethodExtractor({
|
||||
...typescriptMethodConfig,
|
||||
extractFunctionName: tsExtractFunctionName,
|
||||
}),
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -136,5 +172,9 @@ export const javascriptProvider = defineLanguage({
|
||||
importResolver: resolveJavascriptImport,
|
||||
namedBindingExtractor: extractTsNamedBindings,
|
||||
fieldExtractor: createFieldExtractor(javascriptConfig),
|
||||
methodExtractor: createMethodExtractor({
|
||||
...javascriptMethodConfig,
|
||||
extractFunctionName: tsExtractFunctionName,
|
||||
}),
|
||||
builtInNames: BUILT_INS,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Vue language provider.
|
||||
*
|
||||
* Vue SFCs are preprocessed by extracting the <script> / <script setup>
|
||||
* block content, which is then parsed as TypeScript. This provider reuses
|
||||
* nearly all TypeScript infrastructure — queries, type config, field
|
||||
* extraction, and named binding extraction.
|
||||
*
|
||||
* Export detection for <script setup> is handled directly in the parse
|
||||
* worker (all top-level bindings are implicitly exported). The export
|
||||
* checker here is used as fallback for non-setup <script> blocks.
|
||||
*/
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { defineLanguage } from '../language-provider.js';
|
||||
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
|
||||
import { tsExportChecker } from '../export-detection.js';
|
||||
import { resolveVueImport } from '../import-resolvers/vue.js';
|
||||
import { extractTsNamedBindings } from '../named-bindings/typescript.js';
|
||||
import { TYPESCRIPT_QUERIES } from '../tree-sitter-queries.js';
|
||||
import { typescriptFieldExtractor } from '../field-extractors/typescript.js';
|
||||
import { BUILT_INS as TS_BUILT_INS } from './typescript.js';
|
||||
|
||||
const VUE_SPECIFIC_BUILT_INS = [
|
||||
'ref',
|
||||
'reactive',
|
||||
'computed',
|
||||
'watch',
|
||||
'watchEffect',
|
||||
'onMounted',
|
||||
'onUnmounted',
|
||||
'onBeforeMount',
|
||||
'onBeforeUnmount',
|
||||
'onUpdated',
|
||||
'onBeforeUpdate',
|
||||
'nextTick',
|
||||
'defineProps',
|
||||
'defineEmits',
|
||||
'defineExpose',
|
||||
'defineOptions',
|
||||
'defineSlots',
|
||||
'defineModel',
|
||||
'withDefaults',
|
||||
'toRef',
|
||||
'toRefs',
|
||||
'unref',
|
||||
'isRef',
|
||||
'shallowRef',
|
||||
'triggerRef',
|
||||
'provide',
|
||||
'inject',
|
||||
'useSlots',
|
||||
'useAttrs',
|
||||
] as const;
|
||||
|
||||
const VUE_BUILT_INS: ReadonlySet<string> = new Set([...TS_BUILT_INS, ...VUE_SPECIFIC_BUILT_INS]);
|
||||
|
||||
export const vueProvider = defineLanguage({
|
||||
id: SupportedLanguages.Vue,
|
||||
extensions: ['.vue'],
|
||||
treeSitterQueries: TYPESCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
exportChecker: tsExportChecker,
|
||||
importResolver: resolveVueImport,
|
||||
namedBindingExtractor: extractTsNamedBindings,
|
||||
fieldExtractor: typescriptFieldExtractor,
|
||||
builtInNames: VUE_BUILT_INS,
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/c-cpp.ts
|
||||
// Verified against tree-sitter-cpp ^0.23.4
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C/C++ helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find the function_declarator inside a method node, handling pointer/reference
|
||||
* return types where the function_declarator is nested inside a pointer_declarator
|
||||
* or reference_declarator.
|
||||
*/
|
||||
function findFunctionDeclarator(node: SyntaxNode): SyntaxNode | null {
|
||||
const declarator = node.childForFieldName('declarator');
|
||||
if (!declarator) return null;
|
||||
if (declarator.type === 'function_declarator') return declarator;
|
||||
// Recursively unwrap pointer_declarator / reference_declarator chains
|
||||
// (e.g. int** (*pfn)() has pointer_declarator → pointer_declarator → function_declarator)
|
||||
let current: SyntaxNode | null = declarator;
|
||||
while (current) {
|
||||
for (let i = 0; i < current.namedChildCount; i++) {
|
||||
const child = current.namedChild(i);
|
||||
if (child?.type === 'function_declarator') return child;
|
||||
}
|
||||
// Go deeper into nested pointer/reference declarators
|
||||
const next = current.namedChildren.find(
|
||||
(c) => c.type === 'pointer_declarator' || c.type === 'reference_declarator',
|
||||
);
|
||||
current = next ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect `= delete` and `= default` special member function declarations.
|
||||
* These are not callable methods and should be suppressed from extraction.
|
||||
* tree-sitter-cpp ^0.23.4 emits `delete_method_clause` / `default_method_clause`
|
||||
* as named children of the function_definition node.
|
||||
*/
|
||||
function isDeletedOrDefaulted(node: SyntaxNode): boolean {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'delete_method_clause' || child?.type === 'default_method_clause') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract method name from a function_declarator.
|
||||
* The name is the `declarator` field of the function_declarator — typically a
|
||||
* field_identifier, but can be a destructor_name (~ClassName) or operator name.
|
||||
*/
|
||||
function extractCppMethodName(node: SyntaxNode): string | undefined {
|
||||
const funcDecl = findFunctionDeclarator(node);
|
||||
if (!funcDecl) return undefined;
|
||||
|
||||
// Suppress `= delete` and `= default` special members — these are not callable
|
||||
// methods and should not appear in HAS_METHOD edges.
|
||||
if (isDeletedOrDefaulted(node)) return undefined;
|
||||
|
||||
const nameNode = funcDecl.childForFieldName('declarator');
|
||||
if (!nameNode) return undefined;
|
||||
// destructor_name: ~ClassName
|
||||
if (nameNode.type === 'destructor_name') return nameNode.text;
|
||||
// operator_name: operator==, operator+, etc.
|
||||
if (nameNode.type === 'operator_name') return nameNode.text;
|
||||
return nameNode.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract return type from the `type` field of the method node.
|
||||
* tree-sitter-cpp puts the return type as the `type` field on field_declaration
|
||||
* and function_definition nodes.
|
||||
*/
|
||||
function extractCppReturnType(node: SyntaxNode): string | undefined {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (typeNode) {
|
||||
const typeText = typeNode.text?.trim();
|
||||
// C++11 trailing return type: `auto foo() -> ReturnType`
|
||||
// When the declared type is `auto`, check for a trailing_return_type on the
|
||||
// function_declarator which holds the actual return type.
|
||||
if (typeText === 'auto') {
|
||||
const funcDecl = findFunctionDeclarator(node);
|
||||
if (funcDecl) {
|
||||
for (let i = 0; i < funcDecl.namedChildCount; i++) {
|
||||
const child = funcDecl.namedChild(i);
|
||||
if (child?.type === 'trailing_return_type') {
|
||||
// trailing_return_type contains a type_descriptor with the real type
|
||||
const typeDesc = child.firstNamedChild;
|
||||
if (typeDesc) return typeDesc.text?.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return typeText;
|
||||
}
|
||||
// Fallback: first type-like named child (for declarations without type field)
|
||||
const first = node.firstNamedChild;
|
||||
if (
|
||||
first &&
|
||||
(first.type === 'primitive_type' ||
|
||||
first.type === 'type_identifier' ||
|
||||
first.type === 'sized_type_specifier' ||
|
||||
first.type === 'template_type')
|
||||
) {
|
||||
return first.text?.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from the parameter_list inside the function_declarator.
|
||||
*
|
||||
* C/C++ uses parameter_declaration (required) and optional_parameter_declaration
|
||||
* (with default value). Variadic `...` appears as a variadic_parameter_declaration.
|
||||
*/
|
||||
function extractCppParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const funcDecl = findFunctionDeclarator(node);
|
||||
if (!funcDecl) return [];
|
||||
const paramList = funcDecl.childForFieldName('parameters');
|
||||
if (!paramList) return [];
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param) continue;
|
||||
|
||||
switch (param.type) {
|
||||
case 'parameter_declaration': {
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const declNode = param.childForFieldName('declarator');
|
||||
// Extract name — may be wrapped in pointer_declarator or reference_declarator
|
||||
const name = extractParamName(declNode);
|
||||
params.push({
|
||||
name: name ?? typeNode?.text?.trim() ?? '?',
|
||||
type: typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
isOptional: false,
|
||||
isVariadic: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'optional_parameter_declaration': {
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const declNode = param.childForFieldName('declarator');
|
||||
const name = extractParamName(declNode);
|
||||
params.push({
|
||||
name: name ?? typeNode?.text?.trim() ?? '?',
|
||||
type: typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
isOptional: true,
|
||||
isVariadic: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'variadic_parameter_declaration': {
|
||||
// C-style `...` or typed variadic `T... args`
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const declNode = param.childForFieldName('declarator');
|
||||
const name = extractParamName(declNode);
|
||||
params.push({
|
||||
name: name ?? '...',
|
||||
type: typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
isOptional: false,
|
||||
isVariadic: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'variadic_parameter': {
|
||||
// Bare `...` (C-style)
|
||||
params.push({
|
||||
name: '...',
|
||||
type: null,
|
||||
isOptional: false,
|
||||
isVariadic: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// C/C++: bare `...` token in parameter list is an unnamed child (not a named node).
|
||||
// Check all children for the unnamed `...` token when no variadic was detected above.
|
||||
if (!params.some((p) => p.isVariadic)) {
|
||||
for (let i = 0; i < paramList.childCount; i++) {
|
||||
const child = paramList.child(i);
|
||||
if (child && !child.isNamed && child.text === '...') {
|
||||
params.push({
|
||||
name: '...',
|
||||
type: null,
|
||||
isOptional: false,
|
||||
isVariadic: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Extract parameter name, recursively unwrapping pointer/reference declarators. */
|
||||
function extractParamName(declNode: SyntaxNode | null): string | undefined {
|
||||
if (!declNode) return undefined;
|
||||
if (declNode.type === 'identifier') return declNode.text;
|
||||
// Recursively unwrap pointer_declarator / reference_declarator chains (e.g. int** ptr)
|
||||
for (let i = 0; i < declNode.namedChildCount; i++) {
|
||||
const child = declNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'identifier') return child.text;
|
||||
if (child.type === 'pointer_declarator' || child.type === 'reference_declarator') {
|
||||
return extractParamName(child);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect C++ access specifier by walking backwards through siblings.
|
||||
* Mirrors the field extractor pattern in c-cpp.ts.
|
||||
*/
|
||||
function extractCppVisibility(node: SyntaxNode): MethodVisibility {
|
||||
// If this node was unwrapped from a template_declaration, the access_specifier
|
||||
// is a sibling of the template_declaration in field_declaration_list, not of
|
||||
// this node — climb up one level before walking backward.
|
||||
const startNode = node.parent?.type === 'template_declaration' ? node.parent : node;
|
||||
|
||||
let sibling = startNode.previousNamedSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'access_specifier') {
|
||||
const text = sibling.text.replace(':', '').trim();
|
||||
if (text === 'public' || text === 'private' || text === 'protected') return text;
|
||||
}
|
||||
sibling = sibling.previousNamedSibling;
|
||||
}
|
||||
// Default: struct/union = public, class = private
|
||||
const parent = startNode.parent?.parent;
|
||||
return parent?.type === 'struct_specifier' || parent?.type === 'union_specifier'
|
||||
? 'public'
|
||||
: 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect pure virtual methods (`= 0`).
|
||||
* tree-sitter-cpp emits `=` (unnamed) followed by `number_literal` with text `0`.
|
||||
*/
|
||||
function isPureVirtual(node: SyntaxNode): boolean {
|
||||
let foundEquals = false;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (child.text === '=') {
|
||||
foundEquals = true;
|
||||
} else if (foundEquals && child.type === 'number_literal' && child.text === '0') {
|
||||
return true;
|
||||
} else if (foundEquals) {
|
||||
foundEquals = false; // Reset if something else follows `=`
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for a virtual_specifier ('final' or 'override') inside the function_declarator.
|
||||
* In tree-sitter-cpp, these are named children of the function_declarator, not the
|
||||
* method node itself.
|
||||
*/
|
||||
function hasVirtualSpecifier(node: SyntaxNode, keyword: string): boolean {
|
||||
const funcDecl = findFunctionDeclarator(node);
|
||||
if (!funcDecl) return false;
|
||||
for (let i = 0; i < funcDecl.namedChildCount; i++) {
|
||||
const child = funcDecl.namedChild(i);
|
||||
if (child?.type === 'virtual_specifier' && child.text === keyword) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C++ config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// C++ methods appear as field_declaration (declarations) or function_definition
|
||||
// (inline definitions) inside field_declaration_list. The generic extractor
|
||||
// iterates bodyNodeTypes children and matches against methodNodeTypes.
|
||||
//
|
||||
// Key difference from TS/JVM/C#: C++ has no dedicated method_declaration node.
|
||||
// A field_declaration is a method if it contains a function_declarator.
|
||||
// The generic extractor calls extractName() on every methodNodeType node — if
|
||||
// extractName returns undefined (no function_declarator), the method is skipped.
|
||||
//
|
||||
// Known gaps:
|
||||
// - Out-of-class method definitions (void Foo::bar() {}) are not linked as
|
||||
// HAS_METHOD — they appear as top-level function_definition nodes.
|
||||
// This includes namespace-wrapped and nested classes.
|
||||
// - Friend declarations are not extracted.
|
||||
// - Template method declarations with explicit specialization.
|
||||
// - const-qualified method overloads (e.g. begin() vs begin() const) collapse
|
||||
// to the same name — the schema has no isConst field to distinguish them.
|
||||
export const cppMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.CPlusPlus,
|
||||
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'union_specifier'],
|
||||
// declaration covers constructors/destructors; field_declaration covers method
|
||||
// declarations; function_definition covers inline method definitions.
|
||||
// Non-method declarations (variables, typedefs) are filtered by extractName
|
||||
// returning undefined when no function_declarator is found.
|
||||
methodNodeTypes: ['field_declaration', 'function_definition', 'declaration'],
|
||||
bodyNodeTypes: ['field_declaration_list'],
|
||||
|
||||
extractName: extractCppMethodName,
|
||||
extractReturnType: extractCppReturnType,
|
||||
extractParameters: extractCppParameters,
|
||||
extractVisibility: extractCppVisibility,
|
||||
|
||||
isStatic(node) {
|
||||
return hasKeyword(node, 'static');
|
||||
},
|
||||
|
||||
isAbstract(node) {
|
||||
return isPureVirtual(node);
|
||||
},
|
||||
|
||||
isFinal(node) {
|
||||
return hasVirtualSpecifier(node, 'final');
|
||||
},
|
||||
|
||||
isVirtual(node) {
|
||||
// In C++, override and method-level final are only legal on virtual functions,
|
||||
// so they imply virtual even without the explicit keyword.
|
||||
return (
|
||||
hasKeyword(node, 'virtual') ||
|
||||
hasVirtualSpecifier(node, 'override') ||
|
||||
hasVirtualSpecifier(node, 'final')
|
||||
);
|
||||
},
|
||||
|
||||
isOverride(node) {
|
||||
return hasVirtualSpecifier(node, 'override');
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// C config (minimal — C has no classes/methods, only struct function pointers)
|
||||
// Verified against tree-sitter-c 0.23.2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// C does not have methods in the OOP sense. Structs with function pointer fields
|
||||
// are handled by the field extractor. This config exists for completeness but
|
||||
// will rarely match since C structs don't contain function_definition nodes.
|
||||
export const cMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.C,
|
||||
typeDeclarationNodes: ['struct_specifier'],
|
||||
methodNodeTypes: ['function_definition'],
|
||||
bodyNodeTypes: ['field_declaration_list'],
|
||||
|
||||
extractName: extractCppMethodName,
|
||||
extractReturnType: extractCppReturnType,
|
||||
extractParameters: extractCppParameters,
|
||||
|
||||
extractVisibility() {
|
||||
return 'public'; // C has no access control
|
||||
},
|
||||
|
||||
isStatic(node) {
|
||||
return hasKeyword(node, 'static');
|
||||
},
|
||||
|
||||
isAbstract() {
|
||||
return false; // C has no virtual/abstract
|
||||
},
|
||||
|
||||
isFinal() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/csharp.ts
|
||||
// Verified against tree-sitter-c-sharp 0.23.1
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
@@ -186,6 +187,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
|
||||
'destructor_declaration',
|
||||
'operator_declaration',
|
||||
'conversion_operator_declaration',
|
||||
'local_function_statement',
|
||||
],
|
||||
bodyNodeTypes: ['declaration_list'],
|
||||
|
||||
@@ -221,7 +223,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
|
||||
// Constructors and destructors have no return type
|
||||
// operator_declaration and conversion_operator_declaration use 'type' field, not 'returns'
|
||||
const returnsNode = node.childForFieldName('returns');
|
||||
if (returnsNode) return extractSimpleTypeName(returnsNode) ?? returnsNode.text?.trim();
|
||||
if (returnsNode) return returnsNode.text?.trim();
|
||||
// Fallback for operator/conversion declarations that use 'type' as return type field
|
||||
if (node.type === 'operator_declaration' || node.type === 'conversion_operator_declaration') {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/dart.ts
|
||||
// Verified against tree-sitter-dart 1.0.0 (80e23c07)
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dart helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Type node types that represent a return type in function/getter/setter signatures. */
|
||||
const TYPE_NODE_TYPES = new Set([
|
||||
'type_identifier',
|
||||
'generic_type',
|
||||
'function_type',
|
||||
'nullable_type',
|
||||
'void_type',
|
||||
'record_type',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dart method_signature is a WRAPPER node containing one inner signature:
|
||||
* function_signature, constructor_signature, getter_signature, setter_signature,
|
||||
* operator_signature, or factory_constructor_signature.
|
||||
*
|
||||
* Name, parameters, and return type live on the INNER signature, not on
|
||||
* method_signature itself.
|
||||
*/
|
||||
function getInnerSignature(node: SyntaxNode): SyntaxNode | null {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (
|
||||
child &&
|
||||
(child.type === 'function_signature' ||
|
||||
child.type === 'constructor_signature' ||
|
||||
child.type === 'getter_signature' ||
|
||||
child.type === 'setter_signature' ||
|
||||
child.type === 'operator_signature' ||
|
||||
child.type === 'factory_constructor_signature')
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
// `declaration` nodes (abstract methods) also wrap function_signature as a
|
||||
// named child — handled by the loop above.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the method name from a method_signature node.
|
||||
*
|
||||
* Descends into the inner signature to find the name field/identifier.
|
||||
*/
|
||||
function extractDartName(node: SyntaxNode): string | undefined {
|
||||
const inner = getInnerSignature(node);
|
||||
if (!inner) return undefined;
|
||||
|
||||
// constructor_signature name field may include "ClassName.namedCtor" via multiple children.
|
||||
// getter_signature, setter_signature, function_signature all have a 'name' field.
|
||||
if (inner.type === 'operator_signature') {
|
||||
// operator_signature has no 'name' field; name is 'operator' + the operator symbol
|
||||
for (let i = 0; i < inner.namedChildCount; i++) {
|
||||
const child = inner.namedChild(i);
|
||||
if (child?.type === 'binary_operator') {
|
||||
return `operator ${child.text.trim()}`;
|
||||
}
|
||||
}
|
||||
// Check for unnamed operator tokens like []= or ~
|
||||
for (let i = 0; i < inner.childCount; i++) {
|
||||
const child = inner.child(i);
|
||||
if (child && !child.isNamed && child.text.trim() !== 'operator') {
|
||||
const text = child.text.trim();
|
||||
if (text && !TYPE_NODE_TYPES.has(child.type)) {
|
||||
return `operator ${text}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (inner.type === 'getter_signature') {
|
||||
const nameNode = inner.childForFieldName('name');
|
||||
return nameNode?.text;
|
||||
}
|
||||
|
||||
if (inner.type === 'setter_signature') {
|
||||
const nameNode = inner.childForFieldName('name');
|
||||
return nameNode ? `set ${nameNode.text}` : undefined;
|
||||
}
|
||||
|
||||
if (inner.type === 'factory_constructor_signature') {
|
||||
// Collect all identifier children to form "ClassName" or "ClassName.named"
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < inner.childCount; i++) {
|
||||
const child = inner.child(i);
|
||||
if (child?.isNamed && child.type === 'identifier') {
|
||||
parts.push(child.text);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('.') : undefined;
|
||||
}
|
||||
|
||||
// function_signature and constructor_signature both have a 'name' field
|
||||
const nameNode = inner.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
// constructor_signature: name field may be multiple identifiers joined by '.'
|
||||
return nameNode.text;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the return type from the inner signature.
|
||||
*
|
||||
* function_signature children include type nodes before the name.
|
||||
* getter_signature children include type nodes before 'get' keyword.
|
||||
* constructor/setter signatures have no return type.
|
||||
*/
|
||||
function extractDartReturnType(node: SyntaxNode): string | undefined {
|
||||
const inner = getInnerSignature(node);
|
||||
if (!inner) return undefined;
|
||||
|
||||
// Constructors and setters have no return type
|
||||
if (
|
||||
inner.type === 'constructor_signature' ||
|
||||
inner.type === 'setter_signature' ||
|
||||
inner.type === 'factory_constructor_signature'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// For function_signature, getter_signature, operator_signature:
|
||||
// The type node is a named child before the name/operator
|
||||
for (let i = 0; i < inner.namedChildCount; i++) {
|
||||
const child = inner.namedChild(i);
|
||||
if (child && TYPE_NODE_TYPES.has(child.type)) {
|
||||
return child.text?.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from the inner signature's formal_parameter_list.
|
||||
*
|
||||
* Dart parameters can be:
|
||||
* - Positional required: `int x`
|
||||
* - Optional positional: `[int? x]` — wrapped in optional_formal_parameters with '['
|
||||
* - Optional named: `{int? x}` or `{required int x}` — wrapped in optional_formal_parameters with '{'
|
||||
*/
|
||||
function extractDartParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const inner = getInnerSignature(node);
|
||||
if (!inner) return [];
|
||||
|
||||
// getter_signature has no parameters
|
||||
if (inner.type === 'getter_signature') return [];
|
||||
|
||||
// Find formal_parameter_list — it's a child, not a field in function_signature
|
||||
let paramList: SyntaxNode | null = null;
|
||||
if (inner.type === 'constructor_signature' || inner.type === 'factory_constructor_signature') {
|
||||
paramList = inner.childForFieldName('parameters');
|
||||
}
|
||||
if (!paramList) {
|
||||
for (let i = 0; i < inner.namedChildCount; i++) {
|
||||
const child = inner.namedChild(i);
|
||||
if (child?.type === 'formal_parameter_list') {
|
||||
paramList = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!paramList) return [];
|
||||
|
||||
return extractParamsFromList(paramList, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ParameterInfo entries from a formal_parameter_list or optional_formal_parameters node.
|
||||
*/
|
||||
function extractParamsFromList(listNode: SyntaxNode, isOptionalBlock: boolean): ParameterInfo[] {
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
for (let i = 0; i < listNode.namedChildCount; i++) {
|
||||
const child = listNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
|
||||
if (child.type === 'formal_parameter') {
|
||||
params.push(extractSingleParam(child, isOptionalBlock));
|
||||
} else if (child.type === 'optional_formal_parameters') {
|
||||
// Determine if these are named ({}) or positional ([]) optional params
|
||||
// by checking the surrounding delimiters
|
||||
params.push(...extractParamsFromList(child, true));
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single ParameterInfo from a formal_parameter node.
|
||||
*/
|
||||
function extractSingleParam(param: SyntaxNode, isOptionalBlock: boolean): ParameterInfo {
|
||||
const nameNode = param.childForFieldName('name');
|
||||
const name = nameNode?.text ?? '<unknown>';
|
||||
|
||||
// Find the type node
|
||||
let typeName: string | null = null;
|
||||
for (let i = 0; i < param.namedChildCount; i++) {
|
||||
const child = param.namedChild(i);
|
||||
if (child && TYPE_NODE_TYPES.has(child.type)) {
|
||||
typeName = extractSimpleTypeName(child) ?? child.text?.trim() ?? null;
|
||||
break;
|
||||
}
|
||||
// Also check type_identifier
|
||||
if (child?.type === 'type_identifier') {
|
||||
typeName = child.text?.trim() ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for 'required' keyword:
|
||||
// 1. Among children of the param node itself
|
||||
let hasRequired = false;
|
||||
for (let i = 0; i < param.childCount; i++) {
|
||||
const child = param.child(i);
|
||||
if (child && child.text.trim() === 'required') {
|
||||
hasRequired = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 2. In tree-sitter-dart, `required` may be an anonymous sibling token
|
||||
// immediately preceding the formal_parameter inside optional_formal_parameters.
|
||||
if (!hasRequired) {
|
||||
let prev = param.previousSibling;
|
||||
// Skip comma separators
|
||||
while (prev && !prev.isNamed && prev.text.trim() === ',') {
|
||||
prev = prev.previousSibling;
|
||||
}
|
||||
if (prev && !prev.isNamed && prev.text.trim() === 'required') {
|
||||
hasRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A parameter is optional if it's inside an optional_formal_parameters block
|
||||
// and does NOT have the 'required' keyword
|
||||
const isOptional = isOptionalBlock && !hasRequired;
|
||||
|
||||
return {
|
||||
name,
|
||||
type: typeName,
|
||||
isOptional,
|
||||
isVariadic: false, // Dart has no variadic params
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dart visibility: underscore prefix = private, else public.
|
||||
*
|
||||
* We resolve the name by descending into the inner signature.
|
||||
*/
|
||||
function extractDartVisibility(node: SyntaxNode): MethodVisibility {
|
||||
const name = extractDartName(node);
|
||||
if (!name) return 'public';
|
||||
|
||||
// Strip 'set ' or 'operator ' prefix to get the raw name
|
||||
const rawName = name.startsWith('set ')
|
||||
? name.slice(4)
|
||||
: name.startsWith('operator ')
|
||||
? name.slice(9)
|
||||
: name;
|
||||
|
||||
return rawName.startsWith('_') ? 'private' : 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* In tree-sitter-dart, `static` is an anonymous child token of
|
||||
* `method_signature` (or `declaration`), not a previous sibling.
|
||||
*
|
||||
* We check children first, then fall back to previous siblings for
|
||||
* grammar variants.
|
||||
*/
|
||||
function isDartStatic(node: SyntaxNode): boolean {
|
||||
// In tree-sitter-dart, `static` is an anonymous child token of method_signature
|
||||
// (or declaration), not a previous sibling.
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child && !child.isNamed && child.text.trim() === 'static') return true;
|
||||
// Stop once we hit the inner signature — static always precedes it
|
||||
if (child?.isNamed) break;
|
||||
}
|
||||
// Also check previous siblings (fallback for grammar variants)
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.isNamed && sibling.type !== 'annotation') break;
|
||||
if (!sibling.isNamed && sibling.text.trim() === 'static') return true;
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Dart method is abstract if it has no function_body sibling following it.
|
||||
* In the tree-sitter grammar, function_body is a sibling of method_signature
|
||||
* in class_body.
|
||||
*/
|
||||
function isDartAbstract(node: SyntaxNode, _ownerNode: SyntaxNode): boolean {
|
||||
// `declaration` nodes in class_body represent abstract methods (no body, followed by ';').
|
||||
// Note: extension bodies cannot have abstract members in Dart, but `declaration` nodes
|
||||
// do not appear in extension_body in practice since extensions must provide implementations.
|
||||
if (node.type === 'declaration') return true;
|
||||
// For method_signature nodes, check if the next named sibling is a function_body
|
||||
const next = node.nextNamedSibling;
|
||||
return !next || next.type !== 'function_body';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for `async`, `async*`, or `sync*` keyword in the function_body sibling.
|
||||
* The keyword appears as an unnamed child of function_body, or
|
||||
* as a sibling keyword before function_body.
|
||||
*
|
||||
* Dart has three async-like forms: `async` (Future), `async*` (Stream), `sync*` (Iterable).
|
||||
* All three are treated as async for graph purposes.
|
||||
*/
|
||||
function isDartAsync(node: SyntaxNode): boolean {
|
||||
let sibling: SyntaxNode | null = node.nextSibling;
|
||||
let limit = 3;
|
||||
while (sibling && limit > 0) {
|
||||
if (!sibling.isNamed) {
|
||||
const text = sibling.text.trim();
|
||||
if (text === 'async' || text === 'async*' || text === 'sync*') return true;
|
||||
}
|
||||
if (sibling.isNamed && sibling.type === 'function_body') {
|
||||
// Check first child of function_body for async/async*/sync*
|
||||
for (let i = 0; i < sibling.childCount; i++) {
|
||||
const child = sibling.child(i);
|
||||
if (child) {
|
||||
const text = child.text.trim();
|
||||
if (text === 'async' || text === 'async*' || text === 'sync*') return true;
|
||||
}
|
||||
// Stop at first substantial child
|
||||
if (child?.isNamed) break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
sibling = sibling.nextSibling;
|
||||
limit--;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract annotations that appear as sibling nodes before the method_signature
|
||||
* in class_body. Each annotation node is prefixed with '@'.
|
||||
*/
|
||||
function extractDartAnnotations(node: SyntaxNode): string[] {
|
||||
const annotations: string[] = [];
|
||||
let sibling = node.previousNamedSibling;
|
||||
while (sibling && sibling.type === 'annotation') {
|
||||
// annotation node text already includes '@', e.g. "@override"
|
||||
const text = sibling.text?.trim();
|
||||
if (text) {
|
||||
// Normalize: strip arguments from annotation if present, keep just the name
|
||||
// e.g. "@deprecated" -> "@deprecated", "@JsonKey(name: 'id')" -> "@JsonKey"
|
||||
const match = text.match(/^@(\w+)/);
|
||||
if (match) {
|
||||
annotations.unshift('@' + match[1]);
|
||||
} else {
|
||||
annotations.unshift(text);
|
||||
}
|
||||
}
|
||||
sibling = sibling.previousNamedSibling;
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dart config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const dartMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Dart,
|
||||
typeDeclarationNodes: ['class_definition', 'mixin_declaration', 'extension_declaration'],
|
||||
methodNodeTypes: ['method_signature', 'declaration'],
|
||||
bodyNodeTypes: ['class_body', 'extension_body'],
|
||||
|
||||
extractName: extractDartName,
|
||||
extractReturnType: extractDartReturnType,
|
||||
extractParameters: extractDartParameters,
|
||||
extractVisibility: extractDartVisibility,
|
||||
|
||||
isStatic: isDartStatic,
|
||||
isAbstract: isDartAbstract,
|
||||
isFinal: () => false, // Dart methods cannot be 'final'
|
||||
isAsync: isDartAsync,
|
||||
|
||||
extractAnnotations: extractDartAnnotations,
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/go.ts
|
||||
// Verified against tree-sitter-go 0.23.4
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Go helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract the method/function name.
|
||||
* - method_declaration: name is a `field_identifier`
|
||||
* - function_declaration: name is an `identifier`
|
||||
*/
|
||||
function extractGoName(node: SyntaxNode): string | undefined {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
return nameNode?.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract return type from the `result` field.
|
||||
*
|
||||
* Go supports single return (`int`) and multi-return (`(User, error)`).
|
||||
* Multi-return appears as a `parameter_list` — extract the first type.
|
||||
*/
|
||||
function extractGoReturnType(node: SyntaxNode): string | undefined {
|
||||
const result = node.childForFieldName('result');
|
||||
if (!result) return undefined;
|
||||
|
||||
// Single return type (type_identifier, pointer_type, etc.)
|
||||
if (result.type !== 'parameter_list') {
|
||||
return result.text?.trim();
|
||||
}
|
||||
|
||||
// Multi-return: (Type, error) — extract first parameter's type
|
||||
for (let i = 0; i < result.namedChildCount; i++) {
|
||||
const param = result.namedChild(i);
|
||||
if (param?.type === 'parameter_declaration') {
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (typeNode) return typeNode.text?.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from the `parameters` field.
|
||||
*
|
||||
* Go parameter_list contains parameter_declaration nodes with optional
|
||||
* `name` and required `type` fields. Go allows multiple names for one type:
|
||||
* `func(a, b int)` — each name shares the type.
|
||||
*
|
||||
* Handles variadic_parameter_declaration (`...string`).
|
||||
*/
|
||||
function extractGoParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
if (!paramList) return [];
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param) continue;
|
||||
|
||||
if (param.type === 'parameter_declaration') {
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const typeName = typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null;
|
||||
|
||||
// Go allows multiple names for one type: func(a, b int)
|
||||
const names: string[] = [];
|
||||
for (let j = 0; j < param.namedChildCount; j++) {
|
||||
const child = param.namedChild(j);
|
||||
if (child?.type === 'identifier') {
|
||||
names.push(child.text);
|
||||
}
|
||||
}
|
||||
|
||||
if (names.length === 0) {
|
||||
// Unnamed parameter: func(int, string)
|
||||
params.push({ name: `_${i}`, type: typeName, isOptional: false, isVariadic: false });
|
||||
} else {
|
||||
for (const name of names) {
|
||||
params.push({ name, type: typeName, isOptional: false, isVariadic: false });
|
||||
}
|
||||
}
|
||||
} else if (param.type === 'variadic_parameter_declaration') {
|
||||
const nameNode = param.childForFieldName('name');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const typeName = typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null;
|
||||
params.push({
|
||||
name: nameNode?.text ?? `_${i}`,
|
||||
type: typeName,
|
||||
isOptional: false,
|
||||
isVariadic: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Go visibility: uppercase first character = exported (public), lowercase = unexported (private).
|
||||
*/
|
||||
function extractGoVisibility(node: SyntaxNode): MethodVisibility {
|
||||
const name = extractGoName(node);
|
||||
if (!name || name.length === 0) return 'private';
|
||||
const first = name[0];
|
||||
return first === first.toUpperCase() && first !== first.toLowerCase() ? 'public' : 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract receiver type from the `receiver` field.
|
||||
*
|
||||
* The receiver is a parameter_list with one parameter_declaration:
|
||||
* (r *Repo) → pointer_type → type_identifier "Repo"
|
||||
* (r Repo) → type_identifier "Repo"
|
||||
*/
|
||||
function extractGoReceiverType(node: SyntaxNode): string | undefined {
|
||||
const receiver = node.childForFieldName('receiver');
|
||||
if (!receiver) return undefined;
|
||||
|
||||
for (let i = 0; i < receiver.namedChildCount; i++) {
|
||||
const param = receiver.namedChild(i);
|
||||
if (param?.type === 'parameter_declaration') {
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (!typeNode) continue;
|
||||
// Unwrap pointer_type: *User → User
|
||||
const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode;
|
||||
return inner?.text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve owner name from the receiver type.
|
||||
* For function_declaration (no receiver), returns undefined.
|
||||
*/
|
||||
function extractGoOwnerName(node: SyntaxNode): string | undefined {
|
||||
return extractGoReceiverType(node);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const goMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Go,
|
||||
|
||||
// Each method_declaration/function_declaration is treated as its own "container"
|
||||
// for extractFromNode() — not used with extract() in the traditional sense.
|
||||
// method_elem covers interface method signatures (abstract methods).
|
||||
typeDeclarationNodes: ['method_declaration', 'function_declaration', 'method_elem'],
|
||||
methodNodeTypes: ['method_declaration', 'function_declaration', 'method_elem'],
|
||||
bodyNodeTypes: [],
|
||||
|
||||
extractName: extractGoName,
|
||||
extractReturnType: extractGoReturnType,
|
||||
extractParameters: extractGoParameters,
|
||||
extractVisibility: extractGoVisibility,
|
||||
extractReceiverType: extractGoReceiverType,
|
||||
extractOwnerName: extractGoOwnerName,
|
||||
|
||||
isStatic(node) {
|
||||
// Go functions (no receiver) are effectively static
|
||||
return node.type === 'function_declaration';
|
||||
},
|
||||
|
||||
isAbstract(node, _ownerNode) {
|
||||
// Go interface method signatures (method_elem) are abstract — no body
|
||||
return node.type === 'method_elem';
|
||||
},
|
||||
|
||||
isFinal(_node) {
|
||||
return false; // Go has no final methods
|
||||
},
|
||||
};
|
||||
@@ -19,7 +19,9 @@ const INTERFACE_OWNER_TYPES = new Set(['interface_declaration', 'annotation_type
|
||||
function extractReturnTypeFromField(node: SyntaxNode): string | undefined {
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (!typeNode) return undefined;
|
||||
return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
|
||||
// Use .text to preserve full generic types (e.g. List<User>, Stream<T>)
|
||||
// needed by the call resolver for return-type inference.
|
||||
return typeNode.text?.trim();
|
||||
}
|
||||
|
||||
function extractAnnotations(node: SyntaxNode, modifierType: string): string[] {
|
||||
@@ -252,7 +254,7 @@ function extractKotlinReturnType(node: SyntaxNode): string | undefined {
|
||||
child.type === 'nullable_type' ||
|
||||
child.type === 'function_type')
|
||||
) {
|
||||
return extractSimpleTypeName(child) ?? child.text?.trim();
|
||||
return child.text?.trim();
|
||||
}
|
||||
if (child.type === 'function_body') break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/php.ts
|
||||
// Verified against tree-sitter-php 0.23.12
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PHP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Regex to extract PHPDoc @return annotations: `@return User` */
|
||||
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
|
||||
|
||||
/** Node types to skip when walking backwards through siblings for PHPDoc. */
|
||||
const PHPDOC_SKIP_NODE_TYPES: ReadonlySet<string> = new Set(['attribute_list', 'attribute']);
|
||||
|
||||
/**
|
||||
* Normalize a PHPDoc return type for the MethodExtractor.
|
||||
* Strips nullable prefix, null/false/void unions, namespace prefixes, and
|
||||
* rejects uninformative types (mixed, void, self, static, object, array).
|
||||
*/
|
||||
function normalizePhpReturnType(raw: string): string | undefined {
|
||||
let type = raw.startsWith('?') ? raw.slice(1) : raw;
|
||||
const parts = type
|
||||
.split('|')
|
||||
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed');
|
||||
if (parts.length !== 1) return undefined;
|
||||
type = parts[0];
|
||||
const segments = type.split('\\');
|
||||
type = segments[segments.length - 1];
|
||||
if (
|
||||
type === 'mixed' ||
|
||||
type === 'void' ||
|
||||
type === 'self' ||
|
||||
type === 'static' ||
|
||||
type === 'object' ||
|
||||
type === 'array'
|
||||
)
|
||||
return undefined;
|
||||
if (/^\w+(\[\])?$/.test(type) || /^\w+\s*</.test(type)) return type;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk backwards through preceding siblings of `node` to find a PHPDoc
|
||||
* `@return Type` annotation. Skips `attribute_list` nodes (PHP 8 attributes).
|
||||
*/
|
||||
function extractPhpDocReturnType(node: SyntaxNode): string | undefined {
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = PHPDOC_RETURN_RE.exec(sibling.text);
|
||||
if (match) return normalizePhpReturnType(match[1]);
|
||||
} else if (sibling.isNamed && !PHPDOC_SKIP_NODE_TYPES.has(sibling.type)) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const PHP_VIS = new Set<MethodVisibility>(['public', 'private', 'protected']);
|
||||
|
||||
/**
|
||||
* Find the visibility keyword from a visibility_modifier named child.
|
||||
* PHP tree-sitter emits `visibility_modifier` as a named node with text
|
||||
* "public", "private", or "protected".
|
||||
*/
|
||||
function findPhpVisibility(node: SyntaxNode): MethodVisibility {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'visibility_modifier') {
|
||||
const text = child.text.trim() as MethodVisibility;
|
||||
if (PHP_VIS.has(text)) return text;
|
||||
}
|
||||
}
|
||||
return 'public'; // PHP methods are public by default
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for a named modifier child of a specific type.
|
||||
* PHP tree-sitter uses distinct node types: abstract_modifier, final_modifier,
|
||||
* static_modifier — rather than a wrapper `modifiers` node with keyword children.
|
||||
*/
|
||||
function hasModifierNode(node: SyntaxNode, modifierType: string): boolean {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === modifierType) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the return type from a PHP method_declaration node.
|
||||
*
|
||||
* In tree-sitter-php, the return type is not exposed via a named field.
|
||||
* It appears as a type node (primitive_type, named_type, union_type,
|
||||
* optional_type, nullable_type, intersection_type) after the formal_parameters
|
||||
* and a `:` token separator.
|
||||
*
|
||||
* When the AST return type is missing or uninformative (`array` / `iterable`),
|
||||
* falls back to parsing PHPDoc `@return Type` from preceding doc comments.
|
||||
*/
|
||||
function extractPhpReturnType(node: SyntaxNode): string | undefined {
|
||||
const TYPE_NODE_TYPES = new Set([
|
||||
'primitive_type',
|
||||
'named_type',
|
||||
'union_type',
|
||||
'optional_type',
|
||||
'nullable_type',
|
||||
'intersection_type',
|
||||
]);
|
||||
|
||||
let astType: string | undefined;
|
||||
let seenParams = false;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'formal_parameters') {
|
||||
seenParams = true;
|
||||
continue;
|
||||
}
|
||||
// After the parameters node, look for the colon and then the type
|
||||
if (seenParams && child.isNamed && TYPE_NODE_TYPES.has(child.type)) {
|
||||
astType = child.text?.trim();
|
||||
break;
|
||||
}
|
||||
// Stop at body or semicolon
|
||||
if (child.type === 'compound_statement' || (!child.isNamed && child.text === ';')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If AST type is missing or uninformative, try PHPDoc @return fallback
|
||||
if (!astType || astType === 'array' || astType === 'iterable') {
|
||||
const docType = extractPhpDocReturnType(node);
|
||||
if (docType) return docType;
|
||||
}
|
||||
|
||||
return astType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from a PHP method_declaration node.
|
||||
*
|
||||
* PHP parameter types in tree-sitter-php:
|
||||
* - `simple_parameter`: regular parameter with optional type and default
|
||||
* - `variadic_parameter`: `...$param` with optional type
|
||||
* - `property_promotion_parameter`: constructor promotion `private string $name`
|
||||
* (may also be variadic via an ERROR node containing `...`)
|
||||
*/
|
||||
function extractPhpParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
if (!paramList) return [];
|
||||
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param) continue;
|
||||
|
||||
if (param.type === 'simple_parameter') {
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
|
||||
|
||||
// Detect optional: '=' token among children indicates a default value
|
||||
let isOptional = false;
|
||||
for (let j = 0; j < param.childCount; j++) {
|
||||
const c = param.child(j);
|
||||
if (c && !c.isNamed && c.text === '=') {
|
||||
isOptional = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
params.push({
|
||||
name: stripDollar(nameNode.text),
|
||||
type: typeName ?? null,
|
||||
isOptional,
|
||||
isVariadic: false,
|
||||
});
|
||||
} else if (param.type === 'variadic_parameter') {
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
|
||||
|
||||
params.push({
|
||||
name: stripDollar(nameNode.text),
|
||||
type: typeName ?? null,
|
||||
isOptional: false,
|
||||
isVariadic: true,
|
||||
});
|
||||
} else if (param.type === 'property_promotion_parameter') {
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (!nameNode) continue;
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const typeName = typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim()) : null;
|
||||
|
||||
// Detect variadic: an ERROR child containing "..." indicates variadic promotion
|
||||
let isVariadic = false;
|
||||
for (let j = 0; j < param.childCount; j++) {
|
||||
const c = param.child(j);
|
||||
if (c && (c.text === '...' || (c.type === 'ERROR' && c.text === '...'))) {
|
||||
isVariadic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
params.push({
|
||||
name: stripDollar(nameNode.text),
|
||||
type: typeName ?? null,
|
||||
isOptional: false,
|
||||
isVariadic,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Strip leading $ from PHP variable names. */
|
||||
function stripDollar(name: string): string {
|
||||
return name.startsWith('$') ? name.slice(1) : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract PHP 8 attributes (#[...]) from a method_declaration node.
|
||||
*
|
||||
* AST structure: attribute_list → attribute_group → attribute → name child.
|
||||
* Names are prefixed with '#' to distinguish from Java/Kotlin @ annotations.
|
||||
*/
|
||||
function extractPhpAnnotations(node: SyntaxNode): string[] {
|
||||
const annotations: string[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child || child.type !== 'attribute_list') continue;
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const group = child.namedChild(j);
|
||||
if (!group || group.type !== 'attribute_group') continue;
|
||||
for (let k = 0; k < group.namedChildCount; k++) {
|
||||
const attr = group.namedChild(k);
|
||||
if (!attr || attr.type !== 'attribute') continue;
|
||||
const nameNode = attr.firstNamedChild;
|
||||
if (nameNode && nameNode.type === 'name') {
|
||||
annotations.push('#' + nameNode.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PHP config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const phpMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.PHP,
|
||||
typeDeclarationNodes: [
|
||||
'class_declaration',
|
||||
'interface_declaration',
|
||||
'trait_declaration',
|
||||
'enum_declaration',
|
||||
],
|
||||
methodNodeTypes: ['method_declaration', 'function_definition'],
|
||||
bodyNodeTypes: ['declaration_list'],
|
||||
|
||||
extractName(node) {
|
||||
return node.childForFieldName('name')?.text;
|
||||
},
|
||||
|
||||
extractReturnType: extractPhpReturnType,
|
||||
|
||||
extractParameters: extractPhpParameters,
|
||||
|
||||
extractVisibility: findPhpVisibility,
|
||||
|
||||
isStatic(node) {
|
||||
return hasModifierNode(node, 'static_modifier');
|
||||
},
|
||||
|
||||
isAbstract(node, ownerNode) {
|
||||
if (hasModifierNode(node, 'abstract_modifier')) return true;
|
||||
// Interface methods are implicitly abstract when they have no body.
|
||||
// Check ownerNode first, then fall back to walking the parent chain
|
||||
// (needed when called from extractFromNode where ownerNode === node).
|
||||
let isInterface = ownerNode.type === 'interface_declaration';
|
||||
if (!isInterface) {
|
||||
let p = node.parent;
|
||||
while (p) {
|
||||
if (p.type === 'interface_declaration') {
|
||||
isInterface = true;
|
||||
break;
|
||||
}
|
||||
p = p.parent;
|
||||
}
|
||||
}
|
||||
if (isInterface) {
|
||||
const body = node.childForFieldName('body');
|
||||
if (body) return false;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
if (node.namedChild(i)?.type === 'compound_statement') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
isFinal(node) {
|
||||
return hasModifierNode(node, 'final_modifier');
|
||||
},
|
||||
|
||||
extractAnnotations: extractPhpAnnotations,
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/python.ts
|
||||
// Verified against tree-sitter-python 0.23.4
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Python helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Names that represent the instance/class receiver — not real parameters. */
|
||||
const SELF_NAMES = new Set(['self', 'cls']);
|
||||
|
||||
/**
|
||||
* Unwrap a decorated_definition to its inner function_definition.
|
||||
*
|
||||
* tree-sitter-python wraps decorated functions/methods in a `decorated_definition`
|
||||
* node that contains the decorators as children followed by the function_definition.
|
||||
* This is different from TS/JS where decorators are siblings.
|
||||
*/
|
||||
function unwrapDecorated(node: SyntaxNode): SyntaxNode {
|
||||
if (node.type === 'decorated_definition') {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === 'function_definition') return child;
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect decorator names from a decorated_definition wrapper.
|
||||
*
|
||||
* Returns decorator names prefixed with '@'. If the node is a plain
|
||||
* function_definition (no decorators), check if its parent is a
|
||||
* decorated_definition and collect from there.
|
||||
*/
|
||||
function collectDecorators(node: SyntaxNode): SyntaxNode[] {
|
||||
let wrapper: SyntaxNode | null = null;
|
||||
if (node.type === 'decorated_definition') {
|
||||
wrapper = node;
|
||||
} else if (node.parent?.type === 'decorated_definition') {
|
||||
wrapper = node.parent;
|
||||
}
|
||||
if (!wrapper) return [];
|
||||
|
||||
const decorators: SyntaxNode[] = [];
|
||||
for (let i = 0; i < wrapper.namedChildCount; i++) {
|
||||
const child = wrapper.namedChild(i);
|
||||
if (child && child.type === 'decorator') {
|
||||
decorators.push(child);
|
||||
}
|
||||
}
|
||||
return decorators;
|
||||
}
|
||||
|
||||
function extractDecoratorName(decorator: SyntaxNode): string | undefined {
|
||||
// decorator > identifier (simple)
|
||||
// decorator > call > identifier (call-style, e.g. @lru_cache())
|
||||
// decorator > attribute (dotted, e.g. @abc.abstractmethod)
|
||||
const expr = decorator.firstNamedChild;
|
||||
if (!expr) return undefined;
|
||||
|
||||
if (expr.type === 'identifier') return '@' + expr.text;
|
||||
if (expr.type === 'attribute') return '@' + expr.text;
|
||||
if (expr.type === 'call') {
|
||||
const fn = expr.childForFieldName('function');
|
||||
return fn ? '@' + fn.text : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasDecorator(node: SyntaxNode, name: string): boolean {
|
||||
const decorators = collectDecorators(node);
|
||||
for (const dec of decorators) {
|
||||
const decName = extractDecoratorName(dec);
|
||||
if (decName === '@' + name || decName?.endsWith('.' + name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from a Python function_definition.
|
||||
*
|
||||
* Handles: identifier, default_parameter, typed_parameter, typed_default_parameter,
|
||||
* list_splat_pattern (*args), dictionary_splat_pattern (**kwargs), and typed variants.
|
||||
* Skips `self` and `cls` first parameters.
|
||||
*/
|
||||
function extractPythonParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const funcNode = unwrapDecorated(node);
|
||||
const paramList = funcNode.childForFieldName('parameters');
|
||||
if (!paramList) return [];
|
||||
|
||||
const params: ParameterInfo[] = [];
|
||||
let isFirst = true;
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param) continue;
|
||||
|
||||
switch (param.type) {
|
||||
case 'identifier': {
|
||||
// Bare parameter: `self`, `cls`, or untyped `x`
|
||||
if (isFirst && SELF_NAMES.has(param.text)) {
|
||||
isFirst = false;
|
||||
continue;
|
||||
}
|
||||
isFirst = false;
|
||||
params.push({ name: param.text, type: null, isOptional: false, isVariadic: false });
|
||||
break;
|
||||
}
|
||||
case 'default_parameter': {
|
||||
// `x = value` — untyped with default
|
||||
isFirst = false;
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
params.push({ name: nameNode.text, type: null, isOptional: true, isVariadic: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'typed_parameter': {
|
||||
// `x: int` or `*args: str` or `**kwargs: int`
|
||||
// The first named child can be identifier, list_splat_pattern, or dictionary_splat_pattern
|
||||
const inner = param.firstNamedChild;
|
||||
if (!inner) break;
|
||||
|
||||
if (isFirst && inner.type === 'identifier' && SELF_NAMES.has(inner.text)) {
|
||||
isFirst = false;
|
||||
continue;
|
||||
}
|
||||
isFirst = false;
|
||||
|
||||
const typeNode = param.childForFieldName('type');
|
||||
const typeText = typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null;
|
||||
|
||||
if (inner.type === 'list_splat_pattern') {
|
||||
const nameId = inner.firstNamedChild;
|
||||
if (nameId) {
|
||||
params.push({ name: nameId.text, type: typeText, isOptional: false, isVariadic: true });
|
||||
}
|
||||
} else if (inner.type === 'dictionary_splat_pattern') {
|
||||
const nameId = inner.firstNamedChild;
|
||||
if (nameId) {
|
||||
params.push({ name: nameId.text, type: typeText, isOptional: false, isVariadic: true });
|
||||
}
|
||||
} else {
|
||||
params.push({
|
||||
name: inner.text,
|
||||
type: typeText,
|
||||
isOptional: false,
|
||||
isVariadic: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'typed_default_parameter': {
|
||||
// `x: int = 5` — typed with default
|
||||
isFirst = false;
|
||||
const nameNode = param.childForFieldName('name');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
if (nameNode) {
|
||||
params.push({
|
||||
name: nameNode.text,
|
||||
type: typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
isOptional: true,
|
||||
isVariadic: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'list_splat_pattern': {
|
||||
// `*args` (untyped)
|
||||
isFirst = false;
|
||||
const nameId = param.firstNamedChild;
|
||||
if (nameId) {
|
||||
params.push({ name: nameId.text, type: null, isOptional: false, isVariadic: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dictionary_splat_pattern': {
|
||||
// `**kwargs` (untyped)
|
||||
isFirst = false;
|
||||
const nameId = param.firstNamedChild;
|
||||
if (nameId) {
|
||||
params.push({ name: nameId.text, type: null, isOptional: false, isVariadic: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
isFirst = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract return type from the `return_type` field.
|
||||
*
|
||||
* tree-sitter-python uses a `type` field on function_definition for the return
|
||||
* type annotation (e.g. `-> str`). The field contains a type node.
|
||||
*/
|
||||
function extractPythonReturnType(node: SyntaxNode): string | undefined {
|
||||
const funcNode = unwrapDecorated(node);
|
||||
const returnType = funcNode.childForFieldName('return_type');
|
||||
if (!returnType) return undefined;
|
||||
// Use .text to preserve full generic types (e.g. list[User], Dict[str, User])
|
||||
// that the call resolver needs for for-loop iterable and return-type inference.
|
||||
return returnType.text?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract visibility based on Python name-mangling convention.
|
||||
* `__name` (not dunder) = private, `_name` = protected, else public.
|
||||
*/
|
||||
function extractPythonVisibility(node: SyntaxNode): MethodVisibility {
|
||||
const funcNode = unwrapDecorated(node);
|
||||
const nameNode = funcNode.childForFieldName('name');
|
||||
const name = nameNode?.text;
|
||||
if (!name) return 'public';
|
||||
if (name.startsWith('__') && !name.endsWith('__')) return 'private';
|
||||
if (name.startsWith('_') && !(name.startsWith('__') && name.endsWith('__'))) return 'protected';
|
||||
return 'public';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const pythonMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Python,
|
||||
typeDeclarationNodes: ['class_definition'],
|
||||
// Both function_definition and decorated_definition must be listed:
|
||||
// decorated methods appear as decorated_definition in the class body block,
|
||||
// while undecorated methods appear as function_definition directly.
|
||||
methodNodeTypes: ['function_definition', 'decorated_definition'],
|
||||
bodyNodeTypes: ['block'],
|
||||
|
||||
extractName(node) {
|
||||
const funcNode = unwrapDecorated(node);
|
||||
const nameNode = funcNode.childForFieldName('name');
|
||||
return nameNode?.text;
|
||||
},
|
||||
|
||||
extractReturnType: extractPythonReturnType,
|
||||
extractParameters: extractPythonParameters,
|
||||
extractVisibility: extractPythonVisibility,
|
||||
|
||||
isStatic(node) {
|
||||
return hasDecorator(node, 'staticmethod') || hasDecorator(node, 'classmethod');
|
||||
},
|
||||
|
||||
isAbstract(node, _ownerNode) {
|
||||
return hasDecorator(node, 'abstractmethod');
|
||||
},
|
||||
|
||||
isFinal(_node) {
|
||||
return false; // @typing.final (PEP 591) is captured in annotations; isFinal not modeled
|
||||
},
|
||||
|
||||
extractAnnotations(node) {
|
||||
const decorators = collectDecorators(node);
|
||||
const annotations: string[] = [];
|
||||
for (const dec of decorators) {
|
||||
const name = extractDecoratorName(dec);
|
||||
if (name) annotations.push(name);
|
||||
}
|
||||
return annotations;
|
||||
},
|
||||
|
||||
isAsync(node) {
|
||||
const funcNode = unwrapDecorated(node);
|
||||
return hasKeyword(funcNode, 'async');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/ruby.ts
|
||||
// Verified against tree-sitter-ruby 0.23.1
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ruby helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VISIBILITY_MODIFIERS = new Set(['private', 'protected', 'public']);
|
||||
|
||||
/** Regex to extract YARD `@return [Type]` annotations from comments. */
|
||||
const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/;
|
||||
|
||||
/**
|
||||
* Extract the simple type name from a YARD type string.
|
||||
* Handles qualified types ("Models::User" -> "User"), generics ("Array<User>"
|
||||
* -> "Array"), nullable ("String, nil" -> "String"), and rejects ambiguous
|
||||
* unions ("String, Integer" -> undefined).
|
||||
*/
|
||||
function extractYardTypeName(yardType: string): string | undefined {
|
||||
const trimmed = yardType.trim();
|
||||
|
||||
// Bracket-balanced split on commas to handle generics like Hash<Symbol, User>
|
||||
const parts: string[] = [];
|
||||
let depth = 0,
|
||||
start = 0;
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
if (trimmed[i] === '<') depth++;
|
||||
else if (trimmed[i] === '>') depth--;
|
||||
else if (trimmed[i] === ',' && depth === 0) {
|
||||
parts.push(trimmed.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(trimmed.slice(start).trim());
|
||||
const filtered = parts.filter((p) => p !== '' && p !== 'nil');
|
||||
if (filtered.length !== 1) return undefined; // ambiguous union
|
||||
|
||||
const typePart = filtered[0];
|
||||
|
||||
// Qualified: "Models::User" -> "User"
|
||||
const segments = typePart.split('::');
|
||||
const last = segments[segments.length - 1];
|
||||
|
||||
// Generic: "Array<User>" -> "Array"
|
||||
const genericMatch = last.match(/^(\w+)\s*[<{(]/);
|
||||
if (genericMatch) return genericMatch[1];
|
||||
|
||||
// Simple identifier
|
||||
if (/^\w+$/.test(last)) return last;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract visibility for a Ruby method by walking backwards through the
|
||||
* parent body_statement's named children from the method node's position.
|
||||
*
|
||||
* Ruby visibility modifiers (private, protected, public) appear as bare
|
||||
* `identifier` nodes in the body_statement. The most recent modifier
|
||||
* before the method determines its visibility. Default is public.
|
||||
*
|
||||
* Example AST for:
|
||||
* class Foo
|
||||
* private
|
||||
* def secret; end
|
||||
* end
|
||||
*
|
||||
* body_statement
|
||||
* identifier "private" ← index 0
|
||||
* method "def secret" ← index 1
|
||||
*/
|
||||
function extractRubyVisibility(node: SyntaxNode): MethodVisibility {
|
||||
const parent = node.parent;
|
||||
if (!parent) return 'public';
|
||||
|
||||
// Find the index of this method node in the parent's named children
|
||||
let methodIndex = -1;
|
||||
for (let i = 0; i < parent.namedChildCount; i++) {
|
||||
if (parent.namedChild(i) === node) {
|
||||
methodIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (methodIndex < 0) return 'public';
|
||||
|
||||
// Walk backwards from the method node looking for a visibility modifier
|
||||
for (let i = methodIndex - 1; i >= 0; i--) {
|
||||
const sibling = parent.namedChild(i);
|
||||
if (!sibling) continue;
|
||||
if (sibling.type === 'identifier' && VISIBILITY_MODIFIERS.has(sibling.text)) {
|
||||
return sibling.text as MethodVisibility;
|
||||
}
|
||||
// module_function makes instance methods private
|
||||
if (sibling.type === 'identifier' && sibling.text === 'module_function') {
|
||||
return 'private';
|
||||
}
|
||||
}
|
||||
return 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from a Ruby method's method_parameters node.
|
||||
*
|
||||
* Handles: identifier, optional_parameter (default), splat_parameter (*args),
|
||||
* hash_splat_parameter (**kwargs), block_parameter (&block), keyword_parameter.
|
||||
*/
|
||||
function extractRubyParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
if (!paramList) return [];
|
||||
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param) continue;
|
||||
|
||||
switch (param.type) {
|
||||
case 'identifier': {
|
||||
// Plain parameter: def foo(x)
|
||||
params.push({ name: param.text, type: null, isOptional: false, isVariadic: false });
|
||||
break;
|
||||
}
|
||||
case 'optional_parameter': {
|
||||
// Default parameter: def foo(x = 10)
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
params.push({ name: nameNode.text, type: null, isOptional: true, isVariadic: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'splat_parameter': {
|
||||
// Splat: def foo(*args)
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
params.push({ name: nameNode.text, type: null, isOptional: false, isVariadic: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'hash_splat_parameter': {
|
||||
// Double splat: def foo(**kwargs)
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
params.push({ name: nameNode.text, type: null, isOptional: false, isVariadic: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'block_parameter': {
|
||||
// Block: def foo(&block)
|
||||
const nameNode = param.childForFieldName('name');
|
||||
if (nameNode) {
|
||||
params.push({ name: nameNode.text, type: null, isOptional: false, isVariadic: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'keyword_parameter': {
|
||||
// Keyword: def foo(name:) or def foo(name: "default")
|
||||
const nameNode = param.childForFieldName('name');
|
||||
const valueNode = param.childForFieldName('value');
|
||||
if (nameNode) {
|
||||
params.push({
|
||||
name: nameNode.text,
|
||||
type: null,
|
||||
isOptional: !!valueNode,
|
||||
isVariadic: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const rubyMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Ruby,
|
||||
typeDeclarationNodes: ['class', 'module', 'singleton_class'],
|
||||
methodNodeTypes: ['method', 'singleton_method'],
|
||||
bodyNodeTypes: ['body_statement'],
|
||||
|
||||
extractOwnerName(node) {
|
||||
// singleton_class (class << self) inherits the enclosing class/module name
|
||||
if (node.type === 'singleton_class') {
|
||||
let ancestor = node.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class' || ancestor.type === 'module') {
|
||||
const nameNode = ancestor.childForFieldName('name');
|
||||
return nameNode?.text;
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return undefined; // use default resolution for class/module
|
||||
},
|
||||
|
||||
extractName(node) {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
return nameNode?.text;
|
||||
},
|
||||
|
||||
extractReturnType(node) {
|
||||
// Walk backwards through preceding siblings looking for YARD @return [Type].
|
||||
// Try direct siblings first, then fall back to parent (body_statement) siblings
|
||||
// for class methods where the comment may be a sibling of the body_statement.
|
||||
const search = (startNode: SyntaxNode): string | undefined => {
|
||||
let sibling = startNode.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = YARD_RETURN_RE.exec(sibling.text);
|
||||
if (match) return extractYardTypeName(match[1]);
|
||||
} else if (sibling.isNamed) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const result = search(node);
|
||||
if (result) return result;
|
||||
|
||||
if (node.parent?.type === 'body_statement') {
|
||||
return search(node.parent);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
extractParameters: extractRubyParameters,
|
||||
extractVisibility: extractRubyVisibility,
|
||||
|
||||
isStatic(node) {
|
||||
if (node.type === 'singleton_method') return true;
|
||||
// module_function makes following methods callable at module level (static)
|
||||
const parent = node.parent;
|
||||
if (!parent) return false;
|
||||
let methodIndex = -1;
|
||||
for (let i = 0; i < parent.namedChildCount; i++) {
|
||||
if (parent.namedChild(i) === node) {
|
||||
methodIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = methodIndex - 1; i >= 0; i--) {
|
||||
const sibling = parent.namedChild(i);
|
||||
if (!sibling) continue;
|
||||
if (sibling.type === 'identifier' && sibling.text === 'module_function') return true;
|
||||
// Other visibility modifiers override module_function
|
||||
if (sibling.type === 'identifier' && VISIBILITY_MODIFIERS.has(sibling.text)) return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
isAbstract(_node, _ownerNode) {
|
||||
return false; // Ruby has no abstract methods
|
||||
},
|
||||
|
||||
isFinal(_node) {
|
||||
return false; // Ruby has no final methods
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/rust.ts
|
||||
// Verified against tree-sitter-rust 0.23.1
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rust helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract method name from function_item or function_signature_item.
|
||||
* Both use a `name` field containing an identifier.
|
||||
*/
|
||||
function extractRustMethodName(node: SyntaxNode): string | undefined {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
return nameNode?.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract return type from the `return_type` field.
|
||||
* tree-sitter-rust puts the return type (after `->`) as the `return_type` field.
|
||||
*/
|
||||
function extractRustReturnType(node: SyntaxNode): string | undefined {
|
||||
const typeNode = node.childForFieldName('return_type');
|
||||
if (!typeNode) return undefined;
|
||||
return typeNode.text?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters, skipping the self_parameter (handled by extractReceiverType).
|
||||
*
|
||||
* Rust parameters use `pattern` and `type` fields:
|
||||
* parameter { pattern: identifier, type: primitive_type }
|
||||
*/
|
||||
function extractRustParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
if (!paramList) return [];
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param) continue;
|
||||
|
||||
// Skip self_parameter — it is the receiver, not a regular parameter
|
||||
if (param.type === 'self_parameter') continue;
|
||||
|
||||
if (param.type === 'parameter') {
|
||||
const patternNode = param.childForFieldName('pattern');
|
||||
const typeNode = param.childForFieldName('type');
|
||||
params.push({
|
||||
name: patternNode?.text ?? '?',
|
||||
type: typeNode ? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null) : null,
|
||||
isOptional: false,
|
||||
isVariadic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect visibility from visibility_modifier named child.
|
||||
* `pub`, `pub(crate)`, `pub(super)`, `pub(in path)` → public.
|
||||
* Absence → private (Rust default).
|
||||
*/
|
||||
function extractRustVisibility(node: SyntaxNode): MethodVisibility {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'visibility_modifier') return 'public';
|
||||
}
|
||||
return 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect receiver type from the first parameter if it is a self_parameter.
|
||||
*
|
||||
* Variants:
|
||||
* - `self` → "self"
|
||||
* - `&self` → "&self"
|
||||
* - `&mut self` → "&mut self"
|
||||
* - `mut self` → "mut self"
|
||||
* - `self: Box<Self>` → "Box<Self>" (explicit self type)
|
||||
*/
|
||||
function extractRustReceiverType(node: SyntaxNode): string | undefined {
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
if (!paramList) return undefined;
|
||||
const first = paramList.namedChild(0);
|
||||
if (!first || first.type !== 'self_parameter') return undefined;
|
||||
return first.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a function_item has the `async` keyword.
|
||||
* tree-sitter-rust wraps it in a `function_modifiers` named child.
|
||||
*/
|
||||
function isRustAsync(node: SyntaxNode): boolean {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'function_modifiers' && child.text.includes('async')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attributes from preceding sibling attribute_item nodes.
|
||||
*
|
||||
* In tree-sitter-rust, `#[inline]` is an `attribute_item` sibling that precedes
|
||||
* the function_item in the declaration_list, not a child of the function_item.
|
||||
*/
|
||||
function extractRustAnnotations(node: SyntaxNode): string[] {
|
||||
const annotations: string[] = [];
|
||||
let sibling = node.previousNamedSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'attribute_item') {
|
||||
annotations.unshift(sibling.text);
|
||||
} else {
|
||||
// Stop at the first non-attribute sibling — attributes are contiguous
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousNamedSibling;
|
||||
}
|
||||
return annotations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rust config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Rust methods live inside `impl` blocks (concrete implementations) or
|
||||
// `trait` blocks (trait definitions with required/default methods).
|
||||
//
|
||||
// `impl_item` body contains `function_item` nodes for concrete methods.
|
||||
// `trait_item` body contains `function_item` (default methods) and
|
||||
// `function_signature_item` (required/abstract methods without a body).
|
||||
//
|
||||
// ownerName resolution: `impl_item` has no `name` field — the generic
|
||||
// extractor falls back to the first `type_identifier` child, which is the
|
||||
// implementing type (e.g. `impl MyStruct { ... }` → "MyStruct").
|
||||
// `trait_item` uses the standard `name` field.
|
||||
//
|
||||
// Known gaps:
|
||||
// - Macro-generated methods (e.g. derive) are not visible in the AST.
|
||||
// - Unsafe methods are not distinguished (no isUnsafe field in schema).
|
||||
export const rustMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Rust,
|
||||
typeDeclarationNodes: ['impl_item', 'trait_item'],
|
||||
methodNodeTypes: ['function_item', 'function_signature_item'],
|
||||
bodyNodeTypes: ['declaration_list'],
|
||||
|
||||
// For `impl Trait for Struct`, resolve owner to the concrete Struct (after `for`).
|
||||
// For plain `impl Struct`, resolve to Struct (first type_identifier).
|
||||
// For `trait Foo`, let the default name-field resolution handle it.
|
||||
extractOwnerName(node) {
|
||||
if (node.type !== 'impl_item') return undefined;
|
||||
const children = node.children ?? [];
|
||||
const forIdx = children.findIndex((c: SyntaxNode) => c.text === 'for');
|
||||
if (forIdx !== -1) {
|
||||
// impl Trait for Struct — pick the type after `for`
|
||||
const typeNode = children
|
||||
.slice(forIdx + 1)
|
||||
.find(
|
||||
(c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'scoped_type_identifier',
|
||||
);
|
||||
if (typeNode) return typeNode.text;
|
||||
}
|
||||
// Plain `impl Struct` — pick the first type_identifier
|
||||
const first = children.find((c: SyntaxNode) => c.type === 'type_identifier');
|
||||
return first?.text;
|
||||
},
|
||||
|
||||
extractName: extractRustMethodName,
|
||||
extractReturnType: extractRustReturnType,
|
||||
extractParameters: extractRustParameters,
|
||||
extractVisibility: extractRustVisibility,
|
||||
|
||||
isStatic(node) {
|
||||
// A Rust method is an "associated function" (static) if it lacks a
|
||||
// self_parameter as first parameter.
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
if (!paramList) return true;
|
||||
const first = paramList.namedChild(0);
|
||||
return !first || first.type !== 'self_parameter';
|
||||
},
|
||||
|
||||
isAbstract(node, ownerNode) {
|
||||
// Only trait methods without a body (function_signature_item) are abstract.
|
||||
// function_signature_item never has a body field.
|
||||
if (ownerNode.type === 'trait_item' && node.type === 'function_signature_item') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
isFinal() {
|
||||
// Rust has no `final` concept — all methods are effectively sealed
|
||||
// (traits cannot be "overridden" the way Java methods can).
|
||||
return false;
|
||||
},
|
||||
|
||||
extractAnnotations: extractRustAnnotations,
|
||||
extractReceiverType: extractRustReceiverType,
|
||||
isAsync: isRustAsync,
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/swift.ts
|
||||
// Verified against tree-sitter-swift 0.6.0
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { findVisibility, hasKeyword, hasModifier } from '../../field-extractors/configs/helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swift helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SWIFT_VIS = new Set<MethodVisibility>([
|
||||
'public',
|
||||
'private',
|
||||
'fileprivate',
|
||||
'internal',
|
||||
'open',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract the method name from a function_declaration or protocol_function_declaration.
|
||||
*
|
||||
* In tree-sitter-swift, the name is stored in a `simple_identifier` child
|
||||
* (not a 'name' field) on both function_declaration and protocol_function_declaration.
|
||||
*/
|
||||
function extractSwiftName(node: SyntaxNode): string | undefined {
|
||||
// Try field-based name first
|
||||
const nameField = node.childForFieldName('name');
|
||||
if (nameField) return nameField.text;
|
||||
|
||||
// Walk named children for simple_identifier (the function name)
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'simple_identifier') return child.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the return type from a Swift function declaration.
|
||||
*
|
||||
* In tree-sitter-swift, the return type appears in a `type_annotation` child
|
||||
* that follows the parameter list (after `->` in source). It may also appear
|
||||
* as a direct type child (user_type, optional_type, tuple_type, array_type).
|
||||
*/
|
||||
function extractSwiftReturnType(node: SyntaxNode): string | undefined {
|
||||
// Look for the return type — typically the last type_annotation or a type node
|
||||
// that appears after the parameter list.
|
||||
// tree-sitter-swift places the return type inside a type child after '->'
|
||||
let seenParams = false;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'parameter') {
|
||||
seenParams = true;
|
||||
continue;
|
||||
}
|
||||
// The parameter list may be unnamed children; track when we pass ')'
|
||||
if (seenParams || child.type === 'type_annotation') {
|
||||
if (child.type === 'type_annotation') {
|
||||
const inner = child.firstNamedChild;
|
||||
if (inner) return inner.text?.trim();
|
||||
}
|
||||
if (
|
||||
child.type === 'user_type' ||
|
||||
child.type === 'optional_type' ||
|
||||
child.type === 'tuple_type' ||
|
||||
child.type === 'array_type' ||
|
||||
child.type === 'dictionary_type' ||
|
||||
child.type === 'function_type'
|
||||
) {
|
||||
return child.text?.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan all children (named + unnamed) for '->' then grab the next named child
|
||||
let seenArrow = false;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (!child.isNamed && child.text.trim() === '->') {
|
||||
seenArrow = true;
|
||||
continue;
|
||||
}
|
||||
if (seenArrow && child.isNamed) {
|
||||
return child.text?.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract parameters from a Swift function declaration.
|
||||
*
|
||||
* In tree-sitter-swift, parameters are `parameter` named children directly on
|
||||
* the function_declaration node. Each parameter has:
|
||||
* - An external name (label) and/or internal name as simple_identifier children
|
||||
* - A type_annotation child containing the type
|
||||
* - An optional default value after '='
|
||||
* - A possible `...` for variadic parameters
|
||||
*/
|
||||
function extractSwiftParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
// In tree-sitter-swift 0.6.0, parameters are direct children of function_declaration.
|
||||
// Default value tokens ('=', literal) are siblings of the parameter node at the
|
||||
// function_declaration level, not children of the parameter node.
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child?.isNamed || child.type !== 'parameter') continue;
|
||||
|
||||
// Extract parameter name — the last simple_identifier is the internal name
|
||||
let paramName: string | undefined;
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const part = child.namedChild(j);
|
||||
if (part?.type === 'simple_identifier') {
|
||||
paramName = part.text;
|
||||
}
|
||||
}
|
||||
if (!paramName) continue;
|
||||
|
||||
// Extract type — tree-sitter-swift uses user_type (not type_annotation)
|
||||
let typeName: string | null = null;
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const part = child.namedChild(j);
|
||||
if (part?.type === 'user_type' || part?.type === 'type_annotation') {
|
||||
const inner = part.firstNamedChild;
|
||||
if (inner) {
|
||||
typeName = extractSimpleTypeName(inner) ?? inner.text?.trim() ?? null;
|
||||
} else {
|
||||
typeName = part.text?.trim() ?? null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Handle built-in types (array_type, dictionary_type, optional_type, tuple_type)
|
||||
if (part?.type.endsWith('_type') && part.type !== 'simple_identifier') {
|
||||
typeName = extractSimpleTypeName(part) ?? part.text?.trim() ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for default value: '=' token appears as a sibling after the parameter node
|
||||
let isOptional = false;
|
||||
const nextSibling = node.child(i + 1);
|
||||
if (nextSibling && !nextSibling.isNamed && nextSibling.text.trim() === '=') {
|
||||
isOptional = true;
|
||||
}
|
||||
|
||||
// Check for variadic: '...' token among parameter children
|
||||
let isVariadic = false;
|
||||
for (let j = 0; j < child.childCount; j++) {
|
||||
const c = child.child(j);
|
||||
if (c && c.text.trim() === '...') {
|
||||
isVariadic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
params.push({
|
||||
name: paramName,
|
||||
type: typeName,
|
||||
isOptional,
|
||||
isVariadic,
|
||||
});
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a method is inside a protocol.
|
||||
*
|
||||
* A protocol_function_declaration is always abstract. For function_declaration
|
||||
* inside a protocol_body (if it appears there), it's also abstract when it has
|
||||
* no body.
|
||||
*/
|
||||
function isSwiftAbstract(node: SyntaxNode, ownerNode: SyntaxNode): boolean {
|
||||
// protocol_function_declaration nodes are inherently abstract
|
||||
if (node.type === 'protocol_function_declaration') return true;
|
||||
|
||||
// function_declaration inside a protocol is abstract if it has no body
|
||||
if (ownerNode.type === 'protocol_declaration') {
|
||||
const body = node.childForFieldName('body');
|
||||
if (!body) {
|
||||
// Also check for function_body named child
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child?.type === 'function_body') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect attribute nodes from a Swift function declaration.
|
||||
*
|
||||
* In tree-sitter-swift, attributes appear as `attribute` named children
|
||||
* directly on the function_declaration node, or inside a `modifiers` wrapper.
|
||||
* Each attribute node text starts with '@'.
|
||||
*/
|
||||
function extractSwiftAnnotations(node: SyntaxNode): string[] {
|
||||
const annotations: string[] = [];
|
||||
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
|
||||
if (child.type === 'attribute') {
|
||||
const text = child.text?.trim();
|
||||
if (text) {
|
||||
// Normalize: strip arguments, keep just the name
|
||||
// e.g. "@objc(myMethod)" -> "@objc", "@available(iOS 13, *)" -> "@available"
|
||||
const match = text.match(/^@(\w+)/);
|
||||
if (match) {
|
||||
annotations.push('@' + match[1]);
|
||||
} else {
|
||||
annotations.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check inside modifiers wrapper
|
||||
if (child.type === 'modifiers') {
|
||||
for (let j = 0; j < child.namedChildCount; j++) {
|
||||
const mod = child.namedChild(j);
|
||||
if (mod?.type === 'attribute') {
|
||||
const text = mod.text?.trim();
|
||||
if (text) {
|
||||
const match = text.match(/^@(\w+)/);
|
||||
if (match) {
|
||||
annotations.push('@' + match[1]);
|
||||
} else {
|
||||
annotations.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return annotations;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swift config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const swiftMethodConfig: MethodExtractionConfig = {
|
||||
language: SupportedLanguages.Swift,
|
||||
|
||||
// tree-sitter-swift 0.6.0 may use class_declaration for classes, structs, enums, extensions,
|
||||
// and actors — but this cannot be verified until the grammar installs on Node 22+.
|
||||
// TODO: Verify struct_declaration, enum_declaration, extension_declaration, actor_declaration
|
||||
// node types once tree-sitter-swift loads on Node 22, and add them here if they are distinct.
|
||||
// protocol_declaration is a separate, confirmed node type.
|
||||
typeDeclarationNodes: ['class_declaration', 'protocol_declaration'],
|
||||
|
||||
// function_declaration for class/struct methods, protocol_function_declaration for protocol methods
|
||||
methodNodeTypes: ['function_declaration', 'protocol_function_declaration'],
|
||||
|
||||
bodyNodeTypes: ['class_body', 'protocol_body'],
|
||||
|
||||
extractName: extractSwiftName,
|
||||
extractReturnType: extractSwiftReturnType,
|
||||
extractParameters: extractSwiftParameters,
|
||||
|
||||
extractVisibility(node) {
|
||||
return findVisibility(node, SWIFT_VIS, 'internal', 'modifiers');
|
||||
},
|
||||
|
||||
isStatic(node) {
|
||||
return (
|
||||
hasKeyword(node, 'static') ||
|
||||
hasKeyword(node, 'class') ||
|
||||
hasModifier(node, 'modifiers', 'static') ||
|
||||
hasModifier(node, 'modifiers', 'class')
|
||||
);
|
||||
},
|
||||
|
||||
isAbstract: isSwiftAbstract,
|
||||
|
||||
isFinal(node) {
|
||||
return hasKeyword(node, 'final') || hasModifier(node, 'modifiers', 'final');
|
||||
},
|
||||
|
||||
isAsync(node) {
|
||||
return hasKeyword(node, 'async') || hasModifier(node, 'modifiers', 'async');
|
||||
},
|
||||
|
||||
isOverride(node) {
|
||||
return hasKeyword(node, 'override') || hasModifier(node, 'modifiers', 'override');
|
||||
},
|
||||
|
||||
extractAnnotations: extractSwiftAnnotations,
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
// gitnexus/src/core/ingestion/method-extractors/configs/typescript-javascript.ts
|
||||
// Verified against tree-sitter-typescript ^0.23.2, tree-sitter-javascript ^0.23.0
|
||||
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import type {
|
||||
MethodExtractionConfig,
|
||||
ParameterInfo,
|
||||
MethodVisibility,
|
||||
} from '../../method-types.js';
|
||||
import { hasKeyword } from '../../field-extractors/configs/helpers.js';
|
||||
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TS/JS helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VISIBILITY_KEYWORDS = new Set<MethodVisibility>(['public', 'private', 'protected']);
|
||||
|
||||
/**
|
||||
* Extract parameters from formal_parameters.
|
||||
*
|
||||
* Handles both TS node types (required_parameter, optional_parameter, rest_parameter)
|
||||
* and JS node types (identifier, assignment_pattern, rest_pattern), plus destructured
|
||||
* parameters (object_pattern, array_pattern) in both grammars.
|
||||
*/
|
||||
function extractTsJsParameters(node: SyntaxNode): ParameterInfo[] {
|
||||
const paramList = node.childForFieldName('parameters');
|
||||
if (!paramList) return [];
|
||||
const params: ParameterInfo[] = [];
|
||||
|
||||
for (let i = 0; i < paramList.namedChildCount; i++) {
|
||||
const param = paramList.namedChild(i);
|
||||
if (!param) continue;
|
||||
|
||||
switch (param.type) {
|
||||
case 'required_parameter': {
|
||||
const patternNode = param.childForFieldName('pattern');
|
||||
if (!patternNode) break;
|
||||
|
||||
// Skip TS `this` parameter — it's a compile-time type constraint, not a real param
|
||||
if (patternNode.type === 'this') break;
|
||||
|
||||
// Rest parameter: pattern is a rest_pattern (...args) — extract inner identifier
|
||||
const isRest = patternNode.type === 'rest_pattern';
|
||||
const nameNode = isRest ? patternNode.firstNamedChild : patternNode;
|
||||
if (!nameNode) break;
|
||||
|
||||
// type field is a type_annotation — unwrap to get the inner type node
|
||||
const typeAnnotation = param.childForFieldName('type');
|
||||
const typeNode = typeAnnotation?.firstNamedChild;
|
||||
|
||||
// Default value: presence of a 'value' field means isOptional
|
||||
const hasDefault = !!param.childForFieldName('value');
|
||||
|
||||
params.push({
|
||||
name: nameNode.text,
|
||||
type: typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
isOptional: hasDefault,
|
||||
isVariadic: isRest,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'optional_parameter': {
|
||||
const nameNode = param.childForFieldName('pattern');
|
||||
if (!nameNode) break;
|
||||
const typeAnnotation = param.childForFieldName('type');
|
||||
const typeNode = typeAnnotation?.firstNamedChild;
|
||||
params.push({
|
||||
name: nameNode.text,
|
||||
type: typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
isOptional: true,
|
||||
isVariadic: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'rest_parameter': {
|
||||
const nameNode = param.childForFieldName('pattern');
|
||||
if (!nameNode) break;
|
||||
const typeAnnotation = param.childForFieldName('type');
|
||||
const typeNode = typeAnnotation?.firstNamedChild;
|
||||
params.push({
|
||||
name: nameNode.text,
|
||||
type: typeNode
|
||||
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
|
||||
: null,
|
||||
isOptional: false,
|
||||
isVariadic: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'identifier': {
|
||||
// JS: bare parameter name, no type info
|
||||
params.push({ name: param.text, type: null, isOptional: false, isVariadic: false });
|
||||
break;
|
||||
}
|
||||
case 'assignment_pattern': {
|
||||
// JS: param = defaultValue — the left side is the name, isOptional = true
|
||||
const left = param.childForFieldName('left');
|
||||
if (left) {
|
||||
params.push({ name: left.text, type: null, isOptional: true, isVariadic: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'rest_pattern': {
|
||||
// JS: ...args
|
||||
const inner = param.firstNamedChild;
|
||||
if (inner) {
|
||||
params.push({ name: inner.text, type: null, isOptional: false, isVariadic: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'object_pattern':
|
||||
case 'array_pattern': {
|
||||
// Destructured parameter — use full text as name
|
||||
params.push({ name: param.text, type: null, isOptional: false, isVariadic: false });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */
|
||||
const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/;
|
||||
|
||||
/**
|
||||
* Minimal sanitization for JSDoc return types — preserves generic wrappers
|
||||
* (e.g. `Promise<User>`) so that extractReturnTypeName in call-processor
|
||||
* can apply WRAPPER_GENERICS unwrapping. Only strips JSDoc-specific syntax markers.
|
||||
*/
|
||||
function sanitizeJsDocReturnType(raw: string): string | undefined {
|
||||
let type = raw.trim();
|
||||
// Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User
|
||||
if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1);
|
||||
// Strip module: prefix — module:models.User → models.User
|
||||
if (type.startsWith('module:')) type = type.slice(7);
|
||||
// Reject unions (ambiguous)
|
||||
if (type.includes('|')) return undefined;
|
||||
if (!type) return undefined;
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk backwards through preceding siblings looking for a JSDoc comment containing
|
||||
* `@returns {Type}` or `@return {Type}`. Stops at the first non-comment named node
|
||||
* (excluding decorators, which precede methods in TS/JS).
|
||||
*/
|
||||
function extractJsDocReturnType(node: SyntaxNode): string | undefined {
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = JSDOC_RETURN_RE.exec(sibling.text);
|
||||
if (match) return sanitizeJsDocReturnType(match[1]);
|
||||
} else if (sibling.isNamed && sibling.type !== 'decorator') break;
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract return type from return_type field, unwrapping type_annotation.
|
||||
* Falls back to JSDoc `@returns {Type}` when the AST has no return type annotation.
|
||||
*
|
||||
* tree-sitter-typescript uses `return_type` as the field name (not `type` like JVM).
|
||||
* The return_type field points to a type_annotation node that must be unwrapped.
|
||||
*/
|
||||
function extractTsJsReturnType(node: SyntaxNode): string | undefined {
|
||||
const returnType = node.childForFieldName('return_type');
|
||||
if (returnType) {
|
||||
if (returnType.type === 'type_annotation') {
|
||||
const inner = returnType.firstNamedChild;
|
||||
if (inner) return inner.text?.trim();
|
||||
}
|
||||
return returnType.text?.trim();
|
||||
}
|
||||
// AST has no return type annotation — try JSDoc fallback
|
||||
return extractJsDocReturnType(node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract visibility from accessibility_modifier or #private name.
|
||||
*
|
||||
* tree-sitter-typescript emits accessibility_modifier as a named child of method nodes
|
||||
* (not as a modifiers wrapper like JVM). Pass 1 scans for that child; pass 2 checks for
|
||||
* ES2022 private_property_identifier (#name). Default: public.
|
||||
*/
|
||||
function extractTsJsVisibility(node: SyntaxNode): MethodVisibility {
|
||||
// Pass 1: check for accessibility_modifier named child (TS-specific)
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && child.type === 'accessibility_modifier') {
|
||||
const t = child.text.trim();
|
||||
if (VISIBILITY_KEYWORDS.has(t as MethodVisibility)) return t as MethodVisibility;
|
||||
}
|
||||
}
|
||||
// Pass 2: ES2022 private methods (#name) are inherently private
|
||||
const nameNode = node.childForFieldName('name');
|
||||
if (nameNode && nameNode.type === 'private_property_identifier') return 'private';
|
||||
// No accessibility_modifier found — default to public.
|
||||
// Note: tree-sitter-typescript does not wrap modifiers in a 'modifiers' node
|
||||
// (unlike JVM), so there is no wrapper to scan.
|
||||
return 'public';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract decorator names, prefixed with '@'.
|
||||
*
|
||||
* In tree-sitter-typescript, decorators are **siblings** of the method_definition in the
|
||||
* class_body — they are NOT children of the method node. We find them by walking backwards
|
||||
* from the method node through its preceding siblings in the parent body.
|
||||
*/
|
||||
function extractTsJsDecorators(node: SyntaxNode): string[] {
|
||||
const decorators: string[] = [];
|
||||
// Walk backwards via previousNamedSibling to collect consecutive decorator siblings.
|
||||
// This avoids the O(N) index-finding scan through the parent's children.
|
||||
let sibling = node.previousNamedSibling;
|
||||
while (sibling && sibling.type === 'decorator') {
|
||||
const name = extractDecoratorName(sibling);
|
||||
if (name) decorators.unshift(name);
|
||||
sibling = sibling.previousNamedSibling;
|
||||
}
|
||||
return decorators;
|
||||
}
|
||||
|
||||
function extractDecoratorName(decorator: SyntaxNode): string | undefined {
|
||||
const expr = decorator.firstNamedChild;
|
||||
if (!expr) return undefined;
|
||||
if (expr.type === 'call_expression') {
|
||||
const fn = expr.childForFieldName('function');
|
||||
return fn ? '@' + fn.text : undefined;
|
||||
}
|
||||
if (expr.type === 'identifier') return '@' + expr.text;
|
||||
if (expr.type === 'member_expression') return '@' + expr.text;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TS and JS share the same config base. TS-only node types (abstract_class_declaration,
|
||||
// interface_declaration, abstract_method_signature, method_signature, interface_body) are
|
||||
// included because the JS grammar never produces these nodes — they are harmless no-ops.
|
||||
// This mirrors the field extractor's typescript-javascript.ts shared pattern.
|
||||
//
|
||||
// Note: TS and JS share a method config but NOT a field extractor because the TS field
|
||||
// extractor needs a hand-written class for type_alias_declaration object literals and
|
||||
// nested type discovery. Methods have no such requirement.
|
||||
const shared: Omit<MethodExtractionConfig, 'language'> = {
|
||||
typeDeclarationNodes: [
|
||||
'class_declaration',
|
||||
'abstract_class_declaration',
|
||||
'interface_declaration',
|
||||
],
|
||||
// Note: TS constructors are method_definition nodes (name = 'constructor'), so no
|
||||
// explicit constructor_declaration entry is needed (unlike JVM/C# configs).
|
||||
// Known gaps:
|
||||
// - call_signature and construct_signature (e.g., interface Fn { (x: string): void; })
|
||||
// are not extracted — they have no name field and are uncommon in practice.
|
||||
// - class_expression (const Foo = class { ... }) — methods inside class expressions
|
||||
// are not discovered because class_expression is not in typeDeclarationNodes.
|
||||
// - declare module / declare global augmentations — methods inside ambient_module_declaration
|
||||
// wrappers are not surfaced because the top-level walker doesn't descend into them.
|
||||
methodNodeTypes: [
|
||||
'method_definition',
|
||||
'method_signature',
|
||||
'abstract_method_signature',
|
||||
'function_declaration',
|
||||
'generator_function_declaration',
|
||||
'function_signature',
|
||||
],
|
||||
bodyNodeTypes: ['class_body', 'interface_body'],
|
||||
|
||||
extractName(node) {
|
||||
const nameNode = node.childForFieldName('name');
|
||||
return nameNode?.text;
|
||||
},
|
||||
|
||||
extractReturnType: extractTsJsReturnType,
|
||||
extractParameters: extractTsJsParameters,
|
||||
extractVisibility: extractTsJsVisibility,
|
||||
|
||||
isStatic(node) {
|
||||
return hasKeyword(node, 'static');
|
||||
},
|
||||
|
||||
isAbstract(node, ownerNode) {
|
||||
// Explicit abstract keyword on the method itself
|
||||
if (hasKeyword(node, 'abstract')) return true;
|
||||
// Interface methods are implicitly abstract — TS interfaces never have method bodies
|
||||
// (unlike Java default methods), so no !body check needed
|
||||
if (ownerNode.type === 'interface_declaration') return true;
|
||||
return false;
|
||||
},
|
||||
|
||||
isFinal(_node) {
|
||||
return false; // TS/JS has no final/sealed methods
|
||||
},
|
||||
|
||||
extractAnnotations: extractTsJsDecorators,
|
||||
|
||||
isAsync(node) {
|
||||
return hasKeyword(node, 'async');
|
||||
},
|
||||
|
||||
isOverride(node) {
|
||||
return hasKeyword(node, 'override');
|
||||
},
|
||||
};
|
||||
|
||||
export const typescriptMethodConfig: MethodExtractionConfig = {
|
||||
...shared,
|
||||
language: SupportedLanguages.TypeScript,
|
||||
};
|
||||
|
||||
export const javascriptMethodConfig: MethodExtractionConfig = {
|
||||
...shared,
|
||||
language: SupportedLanguages.JavaScript,
|
||||
};
|
||||
@@ -16,8 +16,8 @@ import type {
|
||||
MethodInfo,
|
||||
} from '../method-types.js';
|
||||
|
||||
/** Owner node types where member functions are effectively static (JVM semantics). */
|
||||
const STATIC_OWNER_TYPES = new Set(['companion_object', 'object_declaration']);
|
||||
/** Owner node types where member functions are effectively static (JVM/Ruby semantics). */
|
||||
const STATIC_OWNER_TYPES = new Set(['companion_object', 'object_declaration', 'singleton_class']);
|
||||
|
||||
/**
|
||||
* Create a MethodExtractor from a declarative config.
|
||||
@@ -37,17 +37,27 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt
|
||||
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null {
|
||||
if (!typeDeclarationSet.has(node.type)) return null;
|
||||
|
||||
// Resolve owner name: field-based → type_identifier → simple_identifier → "Companion"
|
||||
// Resolve owner name: config hook → field-based → type_identifier → simple_identifier → "Companion"
|
||||
let ownerName: string | undefined;
|
||||
const nameField = node.childForFieldName('name');
|
||||
if (nameField) {
|
||||
ownerName = nameField.text;
|
||||
} else {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child && (child.type === 'type_identifier' || child.type === 'simple_identifier')) {
|
||||
ownerName = child.text;
|
||||
break;
|
||||
if (config.extractOwnerName) {
|
||||
ownerName = config.extractOwnerName(node);
|
||||
}
|
||||
if (!ownerName) {
|
||||
const nameField = node.childForFieldName('name');
|
||||
if (nameField) {
|
||||
ownerName = nameField.text;
|
||||
} else {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (
|
||||
child &&
|
||||
(child.type === 'type_identifier' ||
|
||||
child.type === 'simple_identifier' ||
|
||||
child.type === 'identifier')
|
||||
) {
|
||||
ownerName = child.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,6 +81,13 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt
|
||||
|
||||
return { ownerName, methods };
|
||||
},
|
||||
|
||||
extractFromNode(node: SyntaxNode, context: MethodExtractorContext): MethodInfo | null {
|
||||
if (!methodNodeSet.has(node.type)) return null;
|
||||
return buildMethod(node, node, context, config);
|
||||
},
|
||||
|
||||
...(config.extractFunctionName ? { extractFunctionName: config.extractFunctionName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,10 +119,17 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set<string>): SyntaxNode[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function addNestedBodies(parent: SyntaxNode, bodyNodeSet: Set<string>, out: SyntaxNode[]): void {
|
||||
function addNestedBodies(
|
||||
parent: SyntaxNode,
|
||||
bodyNodeSet: Set<string>,
|
||||
out: SyntaxNode[],
|
||||
seen?: Set<SyntaxNode>,
|
||||
): void {
|
||||
const visited = seen ?? new Set(out);
|
||||
for (let i = 0; i < parent.namedChildCount; i++) {
|
||||
const child = parent.namedChild(i);
|
||||
if (child && bodyNodeSet.has(child.type) && !out.includes(child)) {
|
||||
if (child && bodyNodeSet.has(child.type) && !visited.has(child)) {
|
||||
visited.add(child);
|
||||
out.push(child);
|
||||
}
|
||||
}
|
||||
@@ -120,9 +144,15 @@ function extractMethodsFromBody(
|
||||
out: MethodInfo[],
|
||||
): void {
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const child = body.namedChild(i);
|
||||
let child = body.namedChild(i);
|
||||
if (!child) continue;
|
||||
|
||||
// C++ template methods are wrapped in template_declaration — unwrap to the inner node
|
||||
if (child.type === 'template_declaration') {
|
||||
const inner = child.namedChildren.find((c) => methodNodeSet.has(c.type));
|
||||
if (inner) child = inner;
|
||||
}
|
||||
|
||||
if (methodNodeSet.has(child.type)) {
|
||||
const method = buildMethod(child, ownerNode, context, config);
|
||||
if (method) out.push(method);
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface MethodExtractor {
|
||||
language: SupportedLanguages;
|
||||
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null;
|
||||
isTypeDeclaration(node: SyntaxNode): boolean;
|
||||
/** Extract method info from a standalone method node (e.g. Go top-level method_declaration). */
|
||||
extractFromNode?(node: SyntaxNode, context: MethodExtractorContext): MethodInfo | null;
|
||||
/** Extract function name + label from an AST node during parent-walk.
|
||||
* Languages with non-standard AST structures (e.g. C/C++ declarator
|
||||
* unwrapping, Swift init/deinit, Rust impl_item) provide this hook
|
||||
* to replace the generic name-field lookup.
|
||||
* Return null to fall through to the generic extractor. */
|
||||
extractFunctionName?(
|
||||
node: SyntaxNode,
|
||||
): { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
|
||||
}
|
||||
|
||||
export interface MethodExtractionConfig {
|
||||
@@ -66,9 +76,16 @@ export interface MethodExtractionConfig {
|
||||
isOverride?: (node: SyntaxNode) => boolean;
|
||||
isAsync?: (node: SyntaxNode) => boolean;
|
||||
isPartial?: (node: SyntaxNode) => boolean;
|
||||
/** Resolve the owner name from a standalone method node (e.g. Go receiver type). */
|
||||
extractOwnerName?: (node: SyntaxNode) => string | undefined;
|
||||
/** Extract a primary constructor from the owner node itself (e.g. C# 12 class Point(int x, int y)). */
|
||||
extractPrimaryConstructor?: (
|
||||
ownerNode: SyntaxNode,
|
||||
context: MethodExtractorContext,
|
||||
) => MethodInfo | null;
|
||||
/** Extract function name + label from an AST node during parent-walk.
|
||||
* Passed through to the MethodExtractor by createMethodExtractor. */
|
||||
extractFunctionName?: (
|
||||
node: SyntaxNode,
|
||||
) => { funcName: string | null; label: import('gitnexus-shared').NodeLabel } | null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Walks the inheritance DAG (EXTENDS/IMPLEMENTS edges), collects methods from
|
||||
* each ancestor via HAS_METHOD edges, detects method-name collisions across
|
||||
* parents, and applies language-specific resolution rules to emit OVERRIDES edges.
|
||||
* parents, and applies language-specific resolution rules to emit METHOD_OVERRIDES edges.
|
||||
*
|
||||
* Language-specific rules:
|
||||
* - C++: leftmost base class in declaration order wins
|
||||
@@ -13,10 +13,10 @@
|
||||
* - Rust: no auto-resolution — requires qualified syntax, resolvedTo = null
|
||||
* - Default: single inheritance — first definition wins
|
||||
*
|
||||
* OVERRIDES edge direction: Class → Method (not Method → Method).
|
||||
* METHOD_OVERRIDES edge direction: Class → Method (not Method → Method).
|
||||
* The source is the child class that inherits conflicting methods,
|
||||
* the target is the winning ancestor method node.
|
||||
* Cypher: MATCH (c:Class)-[r:CodeRelation {type: 'OVERRIDES'}]->(m:Method)
|
||||
* Cypher: MATCH (c:Class)-[r:CodeRelation {type: 'METHOD_OVERRIDES'}]->(m:Method)
|
||||
*/
|
||||
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
@@ -47,6 +47,7 @@ export interface MROResult {
|
||||
entries: MROEntry[];
|
||||
overrideEdges: number;
|
||||
ambiguityCount: number;
|
||||
methodImplementsEdges: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -289,6 +290,10 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
let overrideEdges = 0;
|
||||
let ambiguityCount = 0;
|
||||
|
||||
// Pre-computed maps to avoid redundant BFS in emitMethodImplementsEdges
|
||||
const ancestorsMap = new Map<string, string[]>();
|
||||
const edgeTypesMap = new Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>();
|
||||
|
||||
// Process every class that has at least one parent
|
||||
for (const [classId, directParents] of parentMap) {
|
||||
if (directParents.length === 0) continue;
|
||||
@@ -302,12 +307,16 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
|
||||
// Compute linearized MRO depending on language strategy
|
||||
const provider = getProvider(language);
|
||||
const ancestors = gatherAncestors(classId, parentMap);
|
||||
ancestorsMap.set(classId, ancestors);
|
||||
edgeTypesMap.set(classId, buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType));
|
||||
|
||||
let mroOrder: string[];
|
||||
if (provider.mroStrategy === 'c3') {
|
||||
const c3Result = c3Linearize(classId, parentMap, c3Cache);
|
||||
mroOrder = c3Result ?? gatherAncestors(classId, parentMap);
|
||||
mroOrder = c3Result ?? ancestors;
|
||||
} else {
|
||||
mroOrder = gatherAncestors(classId, parentMap);
|
||||
mroOrder = ancestors;
|
||||
}
|
||||
|
||||
// Get the parent names for the MRO entry
|
||||
@@ -348,11 +357,9 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
// Detect collisions: methods defined in 2+ different ancestors
|
||||
const ambiguities: MethodAmbiguity[] = [];
|
||||
|
||||
// Compute transitive edge types once per class (only needed for implements-split languages)
|
||||
// Use pre-computed transitive edge types (only needed for implements-split languages)
|
||||
const needsEdgeTypes = provider.mroStrategy === 'implements-split';
|
||||
const classEdgeTypes = needsEdgeTypes
|
||||
? buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType)
|
||||
: undefined;
|
||||
const classEdgeTypes = needsEdgeTypes ? edgeTypesMap.get(classId) : undefined;
|
||||
|
||||
for (const [methodName, defs] of methodsByName) {
|
||||
if (defs.length < 2) continue;
|
||||
@@ -401,13 +408,13 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
ambiguityCount++;
|
||||
}
|
||||
|
||||
// Emit OVERRIDES edge if resolution found
|
||||
// Emit METHOD_OVERRIDES edge if resolution found
|
||||
if (resolution.resolvedTo !== null) {
|
||||
graph.addRelationship({
|
||||
id: generateId('OVERRIDES', `${classId}->${resolution.resolvedTo}`),
|
||||
id: generateId('METHOD_OVERRIDES', `${classId}->${resolution.resolvedTo}`),
|
||||
sourceId: classId,
|
||||
targetId: resolution.resolvedTo,
|
||||
type: 'OVERRIDES',
|
||||
type: 'METHOD_OVERRIDES',
|
||||
confidence: resolution.confidence,
|
||||
reason: resolution.reason,
|
||||
});
|
||||
@@ -424,7 +431,389 @@ export function computeMRO(graph: KnowledgeGraph): MROResult {
|
||||
});
|
||||
}
|
||||
|
||||
return { entries, overrideEdges, ambiguityCount };
|
||||
const methodImplementsEdges = emitMethodImplementsEdges(
|
||||
graph,
|
||||
parentMap,
|
||||
methodMap,
|
||||
parentEdgeType,
|
||||
ancestorsMap,
|
||||
edgeTypesMap,
|
||||
);
|
||||
|
||||
return { entries, overrideEdges, ambiguityCount, methodImplementsEdges };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// METHOD_IMPLEMENTS edge emission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if two parameter type arrays match.
|
||||
* When either side has no type info, fall back to parameterCount comparison
|
||||
* (arity-compatible matching). If both have parameterCount and they differ,
|
||||
* return no match. If counts match, return confident match. If either count
|
||||
* is undefined, return lenient (non-confident) match.
|
||||
*
|
||||
* Returns `{ match, confident }`:
|
||||
* - Exact type match → `{ match: true, confident: true }`
|
||||
* - Arity match (both have parameterCount, counts equal) → `{ match: true, confident: true }`
|
||||
* - Lenient (either side lacks types AND lacks parameterCount) → `{ match: true, confident: false }`
|
||||
* - No match → `{ match: false, confident: false }`
|
||||
*/
|
||||
function parameterTypesMatch(
|
||||
a: string[],
|
||||
b: string[],
|
||||
aParamCount?: number,
|
||||
bParamCount?: number,
|
||||
): { match: boolean; confident: boolean } {
|
||||
// If one side is variadic and the other isn't, types may match superficially
|
||||
// but the methods aren't guaranteed to be interchangeable
|
||||
if ((aParamCount === undefined) !== (bParamCount === undefined)) {
|
||||
return { match: true, confident: false };
|
||||
}
|
||||
|
||||
if (a.length === 0 || b.length === 0) {
|
||||
// Fall back to arity check when type info is missing
|
||||
if (aParamCount !== undefined && bParamCount !== undefined) {
|
||||
return { match: aParamCount === bParamCount, confident: aParamCount === bParamCount };
|
||||
}
|
||||
return { match: true, confident: false }; // lenient when either count is unknown
|
||||
}
|
||||
if (a.length !== b.length) return { match: false, confident: false };
|
||||
const exact = a.every((t, i) => t === b[i]);
|
||||
return { match: exact, confident: exact };
|
||||
}
|
||||
|
||||
/**
|
||||
* For each concrete class that implements/extends an interface or trait,
|
||||
* find methods in the class that implement methods defined in the interface
|
||||
* and emit METHOD_IMPLEMENTS edges: ConcreteMethod → InterfaceMethod.
|
||||
*
|
||||
* Method node IDs include a `#<paramCount>` arity suffix, so overloaded
|
||||
* methods with different parameter counts are distinct nodes in the graph.
|
||||
*
|
||||
* **Remaining limitation — same-arity overloads:** When two overloads share
|
||||
* the same parameter count but differ only in types (e.g. `save(int)` vs
|
||||
* `save(String)`), they still collapse to one node ID. This is rare in
|
||||
* practice; a future enhancement may add type-hash disambiguation for
|
||||
* languages with reliable type extraction (see issue #574).
|
||||
*/
|
||||
function emitMethodImplementsEdges(
|
||||
graph: KnowledgeGraph,
|
||||
parentMap: Map<string, string[]>,
|
||||
methodMap: Map<string, string[]>,
|
||||
parentEdgeType: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
|
||||
ancestorsMap: Map<string, string[]>,
|
||||
edgeTypesMap: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
|
||||
): number {
|
||||
let edgeCount = 0;
|
||||
|
||||
for (const [classId, parentIds] of parentMap) {
|
||||
const classNode = graph.getNode(classId);
|
||||
if (!classNode) continue;
|
||||
|
||||
// Interfaces and traits declare contracts — they don't implement them
|
||||
if (classNode.label === 'Interface' || classNode.label === 'Trait') continue;
|
||||
|
||||
// Get this class's own methods
|
||||
const ownMethodIds = methodMap.get(classId) ?? [];
|
||||
|
||||
// Build a lookup: methodName → Array<{methodId, parameterTypes, parameterCount}> for own methods
|
||||
const ownMethodsByName = new Map<
|
||||
string,
|
||||
Array<{ methodId: string; parameterTypes: string[]; parameterCount?: number }>
|
||||
>();
|
||||
for (const methodId of ownMethodIds) {
|
||||
const methodNode = graph.getNode(methodId);
|
||||
if (!methodNode || methodNode.label === 'Property') continue;
|
||||
// Abstract methods don't satisfy interface contracts
|
||||
if (methodNode.properties.isAbstract === true) continue;
|
||||
const name = methodNode.properties.name as string;
|
||||
const parameterTypes = (methodNode.properties.parameterTypes as string[] | undefined) ?? [];
|
||||
const parameterCount = methodNode.properties.parameterCount as number | undefined;
|
||||
let bucket = ownMethodsByName.get(name);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
ownMethodsByName.set(name, bucket);
|
||||
}
|
||||
bucket.push({ methodId, parameterTypes, parameterCount });
|
||||
}
|
||||
|
||||
// Use pre-computed ancestors and edge types; fall back to computing if missing (safety)
|
||||
const allAncestors = ancestorsMap.get(classId) ?? gatherAncestors(classId, parentMap);
|
||||
const ancestorEdgeTypes =
|
||||
edgeTypesMap.get(classId) ?? buildTransitiveEdgeTypes(classId, parentMap, parentEdgeType);
|
||||
|
||||
// Dedup set: avoid duplicate edges from diamond paths
|
||||
const emitted = new Set<string>();
|
||||
|
||||
// For each ancestor, check if it's an interface/trait or classified as IMPLEMENTS
|
||||
for (const ancestorId of allAncestors) {
|
||||
const ancestorNode = graph.getNode(ancestorId);
|
||||
if (!ancestorNode) continue;
|
||||
|
||||
const isInterfaceLike = ancestorNode.label === 'Interface' || ancestorNode.label === 'Trait';
|
||||
const classifiedEdgeType = ancestorEdgeTypes.get(ancestorId);
|
||||
if (!isInterfaceLike && classifiedEdgeType !== 'IMPLEMENTS') continue;
|
||||
|
||||
// Get ancestor's methods
|
||||
const ancestorMethodIds = methodMap.get(ancestorId) ?? [];
|
||||
|
||||
for (const ancestorMethodId of ancestorMethodIds) {
|
||||
const ancestorMethodNode = graph.getNode(ancestorMethodId);
|
||||
if (!ancestorMethodNode || ancestorMethodNode.label === 'Property') continue;
|
||||
|
||||
const ancestorName = ancestorMethodNode.properties.name as string;
|
||||
const ancestorParamTypes =
|
||||
(ancestorMethodNode.properties.parameterTypes as string[] | undefined) ?? [];
|
||||
const ancestorParamCount = ancestorMethodNode.properties.parameterCount as
|
||||
| number
|
||||
| undefined;
|
||||
|
||||
// Find matching method in own class by name + parameterTypes/arity
|
||||
const candidates = ownMethodsByName.get(ancestorName);
|
||||
|
||||
// Unit 3: If no own method matches, walk the EXTENDS chain to find inherited concrete method
|
||||
if (!candidates || candidates.length === 0) {
|
||||
const inherited = findInheritedMethod(
|
||||
classId,
|
||||
ancestorName,
|
||||
ancestorParamTypes,
|
||||
ancestorParamCount,
|
||||
graph,
|
||||
parentMap,
|
||||
methodMap,
|
||||
parentEdgeType,
|
||||
ancestorMethodId,
|
||||
);
|
||||
if (inherited) {
|
||||
const edgeKey = `${inherited.methodId}->${ancestorMethodId}`;
|
||||
if (!emitted.has(edgeKey)) {
|
||||
emitted.add(edgeKey);
|
||||
graph.addRelationship({
|
||||
id: generateId('METHOD_IMPLEMENTS', edgeKey),
|
||||
sourceId: inherited.methodId,
|
||||
targetId: ancestorMethodId,
|
||||
type: 'METHOD_IMPLEMENTS',
|
||||
confidence: inherited.confident ? 1.0 : 0.7,
|
||||
reason: '',
|
||||
});
|
||||
edgeCount++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unit 4: Filter candidates by type/arity match, then check for ambiguity
|
||||
const matching: Array<{
|
||||
methodId: string;
|
||||
parameterTypes: string[];
|
||||
parameterCount?: number;
|
||||
confident: boolean;
|
||||
}> = [];
|
||||
for (const c of candidates) {
|
||||
const result = parameterTypesMatch(
|
||||
c.parameterTypes,
|
||||
ancestorParamTypes,
|
||||
c.parameterCount,
|
||||
ancestorParamCount,
|
||||
);
|
||||
if (result.match) {
|
||||
matching.push({ ...c, confident: result.confident });
|
||||
}
|
||||
}
|
||||
|
||||
if (matching.length === 0) continue;
|
||||
|
||||
// If multiple candidates match at name+arity level, emit no edge (ambiguous)
|
||||
if (matching.length > 1) continue;
|
||||
|
||||
const winner = matching[0];
|
||||
const edgeKey = `${winner.methodId}->${ancestorMethodId}`;
|
||||
if (emitted.has(edgeKey)) continue;
|
||||
emitted.add(edgeKey);
|
||||
|
||||
graph.addRelationship({
|
||||
id: generateId('METHOD_IMPLEMENTS', edgeKey),
|
||||
sourceId: winner.methodId,
|
||||
targetId: ancestorMethodId,
|
||||
type: 'METHOD_IMPLEMENTS',
|
||||
confidence: winner.confident ? 1.0 : 0.7,
|
||||
reason: '',
|
||||
});
|
||||
edgeCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edgeCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the class's EXTENDS chain to find the nearest concrete method matching
|
||||
* the given name and parameter signature. If the EXTENDS chain yields no match,
|
||||
* fall back to IMPLEMENTS parents and check for non-abstract default methods
|
||||
* (e.g. Java default interface methods, Kotlin interface defaults).
|
||||
* Returns the first matching method found in BFS order, or null.
|
||||
*/
|
||||
function findInheritedMethod(
|
||||
classId: string,
|
||||
methodName: string,
|
||||
targetParamTypes: string[],
|
||||
targetParamCount: number | undefined,
|
||||
graph: KnowledgeGraph,
|
||||
parentMap: Map<string, string[]>,
|
||||
methodMap: Map<string, string[]>,
|
||||
parentEdgeType: Map<string, Map<string, 'EXTENDS' | 'IMPLEMENTS'>>,
|
||||
/** Method ID to exclude from results (prevents self-edges when the ancestor
|
||||
* method being matched lives on an IMPLEMENTS parent). */
|
||||
excludeMethodId?: string,
|
||||
): { methodId: string; parameterTypes: string[]; confident: boolean } | null {
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [];
|
||||
|
||||
// Seed with direct EXTENDS parents only
|
||||
const directParents = parentMap.get(classId) ?? [];
|
||||
const directEdges = parentEdgeType.get(classId);
|
||||
for (const pid of directParents) {
|
||||
const et = directEdges?.get(pid);
|
||||
if (et === 'EXTENDS') {
|
||||
// Also check that the parent is not an Interface/Trait
|
||||
const parentNode = graph.getNode(pid);
|
||||
if (parentNode && parentNode.label !== 'Interface' && parentNode.label !== 'Trait') {
|
||||
queue.push(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Level-order BFS: process all ancestors at the current depth before
|
||||
// advancing. Once any match is found at depth D, finish that depth and stop.
|
||||
// Diamond dedup: same methodId via two paths at the same depth = 1 match.
|
||||
let currentLevel = [...queue];
|
||||
|
||||
while (currentLevel.length > 0) {
|
||||
const matches = new Map<
|
||||
string,
|
||||
{ methodId: string; parameterTypes: string[]; confident: boolean }
|
||||
>();
|
||||
const nextLevel: string[] = [];
|
||||
|
||||
for (const ancestorId of currentLevel) {
|
||||
if (visited.has(ancestorId)) continue;
|
||||
visited.add(ancestorId);
|
||||
|
||||
// Check this ancestor's methods
|
||||
const methods = methodMap.get(ancestorId) ?? [];
|
||||
for (const mid of methods) {
|
||||
const mNode = graph.getNode(mid);
|
||||
if (!mNode || mNode.label === 'Property') continue;
|
||||
// Abstract inherited methods don't count as concrete implementations
|
||||
if (mNode.properties.isAbstract === true) continue;
|
||||
if (mNode.properties.name !== methodName) continue;
|
||||
|
||||
const mParamTypes = (mNode.properties.parameterTypes as string[] | undefined) ?? [];
|
||||
const mParamCount = mNode.properties.parameterCount as number | undefined;
|
||||
const ptResult = parameterTypesMatch(
|
||||
mParamTypes,
|
||||
targetParamTypes,
|
||||
mParamCount,
|
||||
targetParamCount,
|
||||
);
|
||||
if (ptResult.match) {
|
||||
matches.set(mid, {
|
||||
methodId: mid,
|
||||
parameterTypes: mParamTypes,
|
||||
confident: ptResult.confident,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Collect EXTENDS parents for the next depth level
|
||||
const grandparents = parentMap.get(ancestorId) ?? [];
|
||||
const ancestorEdges = parentEdgeType.get(ancestorId);
|
||||
for (const gp of grandparents) {
|
||||
if (visited.has(gp)) continue;
|
||||
const gpEdge = ancestorEdges?.get(gp);
|
||||
if (gpEdge === 'EXTENDS') {
|
||||
const gpNode = graph.getNode(gp);
|
||||
if (gpNode && gpNode.label !== 'Interface' && gpNode.label !== 'Trait') {
|
||||
nextLevel.push(gp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If any matches found at this depth, decide and stop
|
||||
if (matches.size === 1) return matches.values().next().value!;
|
||||
if (matches.size > 1) return null; // ambiguous at same depth
|
||||
|
||||
currentLevel = nextLevel;
|
||||
}
|
||||
|
||||
// ── Second pass: walk IMPLEMENTS parents AND their interface ancestry ──
|
||||
// Only reached when the EXTENDS chain yielded no match.
|
||||
// BFS through interface/trait hierarchy to find default (non-abstract) methods.
|
||||
const implBfsQueue: string[] = [];
|
||||
for (const pid of directParents) {
|
||||
const et = directEdges?.get(pid);
|
||||
if (et === 'IMPLEMENTS') {
|
||||
implBfsQueue.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all matches from the IMPLEMENTS BFS — return null if ambiguous (>1 match)
|
||||
const implMatches: Array<{
|
||||
methodId: string;
|
||||
parameterTypes: string[];
|
||||
confident: boolean;
|
||||
}> = [];
|
||||
const implVisited = new Set<string>();
|
||||
while (implBfsQueue.length > 0) {
|
||||
const ifaceId = implBfsQueue.shift()!;
|
||||
if (implVisited.has(ifaceId)) continue;
|
||||
implVisited.add(ifaceId);
|
||||
|
||||
// Only process Interface/Trait nodes — Dart `implements Class` does not
|
||||
// inherit method bodies, so Class/Struct/Enum parents must be skipped.
|
||||
const ifaceNode = graph.getNode(ifaceId);
|
||||
if (!ifaceNode || (ifaceNode.label !== 'Interface' && ifaceNode.label !== 'Trait')) continue;
|
||||
|
||||
// Check this interface/trait's methods for a non-abstract default
|
||||
const methods = methodMap.get(ifaceId) ?? [];
|
||||
for (const mid of methods) {
|
||||
if (mid === excludeMethodId) continue; // prevent self-edges
|
||||
const mNode = graph.getNode(mid);
|
||||
if (!mNode || mNode.label === 'Property') continue;
|
||||
if (mNode.properties.isAbstract === true) continue;
|
||||
if (mNode.properties.name !== methodName) continue;
|
||||
|
||||
const mParamTypes = (mNode.properties.parameterTypes as string[] | undefined) ?? [];
|
||||
const mParamCount = mNode.properties.parameterCount as number | undefined;
|
||||
const ptResult = parameterTypesMatch(
|
||||
mParamTypes,
|
||||
targetParamTypes,
|
||||
mParamCount,
|
||||
targetParamCount,
|
||||
);
|
||||
if (ptResult.match) {
|
||||
implMatches.push({
|
||||
methodId: mid,
|
||||
parameterTypes: mParamTypes,
|
||||
confident: ptResult.confident,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Walk this interface's parents (interface-extends-interface chains)
|
||||
const ifaceParents = parentMap.get(ifaceId) ?? [];
|
||||
for (const gp of ifaceParents) {
|
||||
if (!implVisited.has(gp)) implBfsQueue.push(gp);
|
||||
}
|
||||
}
|
||||
|
||||
// Ambiguous: multiple interfaces provide the same default method
|
||||
if (implMatches.length === 1) return implMatches[0];
|
||||
return null; // 0 matches or ambiguous (>1)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,19 +6,22 @@ import { getProvider } from './languages/index.js';
|
||||
import { generateId } from '../../lib/utils.js';
|
||||
import { SymbolTable } from './symbol-table.js';
|
||||
import { ASTCache } from './ast-cache.js';
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
|
||||
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
|
||||
import { yieldToEventLoop } from './utils/event-loop.js';
|
||||
import {
|
||||
getDefinitionNodeFromCaptures,
|
||||
findEnclosingClassId,
|
||||
extractMethodSignature,
|
||||
findEnclosingClassInfo,
|
||||
getLabelFromCaptures,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
type SyntaxNode,
|
||||
type EnclosingClassInfo,
|
||||
} from './utils/ast-helpers.js';
|
||||
import { detectFrameworkFromAST } from './framework-detection.js';
|
||||
import { buildTypeEnv } from './type-env.js';
|
||||
import type { FieldInfo, FieldExtractorContext } from './field-types.js';
|
||||
import type { MethodInfo } from './method-types.js';
|
||||
import { buildMethodProps, arityForIdFromInfo } from './utils/method-props.js';
|
||||
import type { LanguageProvider } from './language-provider.js';
|
||||
import { WorkerPool } from './workers/worker-pool.js';
|
||||
import type {
|
||||
@@ -184,14 +187,17 @@ const processParsingWithWorkers = async (
|
||||
|
||||
// Inline caches to avoid repeated parent-walks per node (same pattern as parse-worker.ts).
|
||||
// Keyed by tree-sitter node reference — cleared at the start of each file.
|
||||
const classIdCache = new Map<SyntaxNode, string | null>();
|
||||
const classInfoCache = new Map<SyntaxNode, EnclosingClassInfo | null>();
|
||||
const exportCache = new Map<SyntaxNode, boolean>();
|
||||
|
||||
const cachedFindEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
|
||||
const cached = classIdCache.get(node);
|
||||
const cachedFindEnclosingClassInfo = (
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
): EnclosingClassInfo | null => {
|
||||
const cached = classInfoCache.get(node);
|
||||
if (cached !== undefined) return cached;
|
||||
const result = findEnclosingClassId(node, filePath);
|
||||
classIdCache.set(node, result);
|
||||
const result = findEnclosingClassInfo(node, filePath);
|
||||
classInfoCache.set(node, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -210,6 +216,13 @@ const cachedExportCheck = (
|
||||
// FieldExtractor cache for sequential path — same pattern as parse-worker.ts
|
||||
const seqFieldInfoCache = new Map<number, Map<string, FieldInfo>>();
|
||||
|
||||
// MethodExtractor cache for sequential path — avoids re-traversing the same class
|
||||
// body once per method. Keyed on classNode.id (tree-sitter node identity number).
|
||||
const seqMethodExtractCache = new Map<
|
||||
number,
|
||||
{ ownerName: string | undefined; methods: MethodInfo[] } | null
|
||||
>();
|
||||
|
||||
function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
@@ -259,9 +272,10 @@ const processParsingSequential = async (
|
||||
const file = files[i];
|
||||
|
||||
// Reset memoization before each new file (node refs are per-tree)
|
||||
classIdCache.clear();
|
||||
classInfoCache.clear();
|
||||
exportCache.clear();
|
||||
seqFieldInfoCache.clear();
|
||||
seqMethodExtractCache.clear();
|
||||
|
||||
onFileProgress?.(i + 1, total, file.path);
|
||||
|
||||
@@ -280,6 +294,18 @@ const processParsingSequential = async (
|
||||
// Skip files larger than the max tree-sitter buffer (32 MB)
|
||||
if (file.content.length > TREE_SITTER_MAX_BUFFER) continue;
|
||||
|
||||
// Vue SFC preprocessing: extract <script> block content
|
||||
let parseContent = file.content;
|
||||
let lineOffset = 0;
|
||||
let isVueSetup = false;
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const extracted = extractVueScript(file.content);
|
||||
if (!extracted) continue; // skip .vue files with no script block
|
||||
parseContent = extracted.scriptContent;
|
||||
lineOffset = extracted.lineOffset;
|
||||
isVueSetup = extracted.isSetup;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadLanguage(language, file.path);
|
||||
} catch {
|
||||
@@ -288,8 +314,8 @@ const processParsingSequential = async (
|
||||
|
||||
let tree;
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent.length),
|
||||
});
|
||||
} catch (parseError) {
|
||||
console.warn(`Skipping unparseable file: ${file.path}`);
|
||||
@@ -317,7 +343,10 @@ const processParsingSequential = async (
|
||||
|
||||
// Build per-file type environment for FieldExtractor context (lightweight — skipped if no fieldExtractor)
|
||||
const typeEnv = provider.fieldExtractor
|
||||
? buildTypeEnv(tree, language, { enclosingFunctionFinder: provider.enclosingFunctionFinder })
|
||||
? buildTypeEnv(tree, language, {
|
||||
enclosingFunctionFinder: provider.enclosingFunctionFinder,
|
||||
extractFunctionName: provider.methodExtractor?.extractFunctionName,
|
||||
})
|
||||
: null;
|
||||
|
||||
matches.forEach((match) => {
|
||||
@@ -337,84 +366,127 @@ const processParsingSequential = async (
|
||||
|
||||
const definitionNodeForRange = getDefinitionNodeFromCaptures(captureMap);
|
||||
const startLine = definitionNodeForRange
|
||||
? definitionNodeForRange.startPosition.row
|
||||
? definitionNodeForRange.startPosition.row + lineOffset
|
||||
: nameNode
|
||||
? nameNode.startPosition.row
|
||||
: 0;
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
|
||||
? nameNode.startPosition.row + lineOffset
|
||||
: lineOffset;
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
|
||||
// Compute enclosing class BEFORE node ID — needed to qualify method IDs
|
||||
const needsOwner =
|
||||
nodeLabel === 'Method' ||
|
||||
nodeLabel === 'Constructor' ||
|
||||
nodeLabel === 'Property' ||
|
||||
nodeLabel === 'Function';
|
||||
const enclosingClassInfo = needsOwner
|
||||
? cachedFindEnclosingClassInfo(nameNode || definitionNodeForRange, file.path)
|
||||
: null;
|
||||
const enclosingClassId = enclosingClassInfo?.classId ?? null;
|
||||
|
||||
// Qualify method/property IDs with enclosing class name to avoid collisions
|
||||
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak"
|
||||
const qualifiedName = enclosingClassInfo
|
||||
? `${enclosingClassInfo.className}.${nodeName}`
|
||||
: nodeName;
|
||||
|
||||
// Extract method metadata for Function/Method/Constructor nodes BEFORE generating
|
||||
// the node ID — parameterCount is needed to disambiguate overloaded methods.
|
||||
// Use the per-language MethodExtractor for method metadata (isAbstract, isStatic,
|
||||
// visibility, annotations, parameterCount, parameterTypes, returnType, etc.).
|
||||
const isMethodLike =
|
||||
nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor';
|
||||
let methodProps: Record<string, unknown> = {};
|
||||
let arityForId: number | undefined; // raw param count for ID, even for variadic
|
||||
if (isMethodLike && definitionNode) {
|
||||
let enriched = false;
|
||||
|
||||
if (provider.methodExtractor) {
|
||||
// Try class-based extraction (method inside a class/struct/trait body)
|
||||
const classNode = seqFindEnclosingClassNode(definitionNode);
|
||||
if (classNode) {
|
||||
// Cache extract() results per class node to avoid re-traversing the
|
||||
// same class body for every method it contains (O(N) -> O(1) per hit).
|
||||
let result:
|
||||
| { ownerName: string | undefined; methods: MethodInfo[] }
|
||||
| null
|
||||
| undefined = seqMethodExtractCache.get(classNode.id);
|
||||
if (result === undefined) {
|
||||
result =
|
||||
provider.methodExtractor.extract(classNode, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
}) ?? null;
|
||||
seqMethodExtractCache.set(classNode.id, result);
|
||||
}
|
||||
if (result?.methods?.length) {
|
||||
const defLine = definitionNode.startPosition.row + 1;
|
||||
const info = result.methods.find((m) => m.name === nodeName && m.line === defLine);
|
||||
if (info) {
|
||||
enriched = true;
|
||||
arityForId = arityForIdFromInfo(info);
|
||||
methodProps = buildMethodProps(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For top-level methods (e.g. Go method_declaration), try extractFromNode
|
||||
if (!enriched && provider.methodExtractor.extractFromNode) {
|
||||
const info = provider.methodExtractor.extractFromNode(definitionNode, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
if (info) {
|
||||
enriched = true;
|
||||
arityForId = arityForIdFromInfo(info);
|
||||
methodProps = buildMethodProps(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
|
||||
// Functions are not suffixed — they don't overload by name in the same scope.
|
||||
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
|
||||
const arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`);
|
||||
const frameworkHint = definitionNode
|
||||
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
|
||||
: null;
|
||||
|
||||
// Extract method signature for Method/Constructor nodes
|
||||
const methodSig =
|
||||
nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor'
|
||||
? extractMethodSignature(definitionNode)
|
||||
: undefined;
|
||||
|
||||
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
|
||||
// Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
|
||||
if (
|
||||
methodSig &&
|
||||
(!methodSig.returnType ||
|
||||
methodSig.returnType === 'array' ||
|
||||
methodSig.returnType === 'iterable') &&
|
||||
definitionNode
|
||||
) {
|
||||
const tc = provider.typeConfig;
|
||||
if (tc?.extractReturnType) {
|
||||
const docReturn = tc.extractReturnType(definitionNode);
|
||||
if (docReturn) methodSig.returnType = docReturn;
|
||||
}
|
||||
}
|
||||
|
||||
const node: GraphNode = {
|
||||
id: nodeId,
|
||||
label: nodeLabel as NodeLabel,
|
||||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: definitionNodeForRange ? definitionNodeForRange.startPosition.row : startLine,
|
||||
endLine: definitionNodeForRange ? definitionNodeForRange.endPosition.row : startLine,
|
||||
startLine: definitionNodeForRange
|
||||
? definitionNodeForRange.startPosition.row + lineOffset
|
||||
: startLine,
|
||||
endLine: definitionNodeForRange
|
||||
? definitionNodeForRange.endPosition.row + lineOffset
|
||||
: startLine,
|
||||
language: language,
|
||||
isExported: cachedExportCheck(
|
||||
provider.exportChecker,
|
||||
nameNode || definitionNodeForRange,
|
||||
nodeName,
|
||||
),
|
||||
isExported:
|
||||
language === SupportedLanguages.Vue && isVueSetup
|
||||
? isVueSetupTopLevel(nameNode || definitionNodeForRange)
|
||||
: cachedExportCheck(
|
||||
provider.exportChecker,
|
||||
nameNode || definitionNodeForRange,
|
||||
nodeName,
|
||||
),
|
||||
...(frameworkHint
|
||||
? {
|
||||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
astFrameworkReason: frameworkHint.reason,
|
||||
}
|
||||
: {}),
|
||||
...(methodSig
|
||||
? {
|
||||
parameterCount: methodSig.parameterCount,
|
||||
...(methodSig.requiredParameterCount !== undefined
|
||||
? { requiredParameterCount: methodSig.requiredParameterCount }
|
||||
: {}),
|
||||
...(methodSig.parameterTypes ? { parameterTypes: methodSig.parameterTypes } : {}),
|
||||
returnType: methodSig.returnType,
|
||||
}
|
||||
: {}),
|
||||
...methodProps,
|
||||
},
|
||||
};
|
||||
|
||||
graph.addNode(node);
|
||||
|
||||
// Compute enclosing class for Method/Constructor/Property/Function — used for both ownerId and HAS_METHOD
|
||||
// Function is included because Kotlin/Rust/Python capture class methods as Function nodes
|
||||
const needsOwner =
|
||||
nodeLabel === 'Method' ||
|
||||
nodeLabel === 'Constructor' ||
|
||||
nodeLabel === 'Property' ||
|
||||
nodeLabel === 'Function';
|
||||
const enclosingClassId = needsOwner
|
||||
? cachedFindEnclosingClassId(nameNode || definitionNodeForRange, file.path)
|
||||
: null;
|
||||
// enclosingClassId already computed above (before nodeId generation)
|
||||
|
||||
// Extract declared type and field metadata for Property nodes
|
||||
let declaredType: string | undefined;
|
||||
@@ -441,7 +513,7 @@ const processParsingSequential = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
// All 14 languages register a FieldExtractor — no fallback needed.
|
||||
// All 15 tree-sitter languages register a FieldExtractor — no fallback needed.
|
||||
}
|
||||
|
||||
// Apply field metadata to the graph node retroactively
|
||||
@@ -451,10 +523,10 @@ const processParsingSequential = async (
|
||||
if (declaredType !== undefined) node.properties.declaredType = declaredType;
|
||||
|
||||
symbolTable.add(file.path, nodeName, nodeId, nodeLabel, {
|
||||
parameterCount: methodSig?.parameterCount,
|
||||
requiredParameterCount: methodSig?.requiredParameterCount,
|
||||
parameterTypes: methodSig?.parameterTypes,
|
||||
returnType: methodSig?.returnType,
|
||||
parameterCount: methodProps.parameterCount as number | undefined,
|
||||
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
|
||||
parameterTypes: methodProps.parameterTypes as string[] | undefined,
|
||||
returnType: methodProps.returnType as string | undefined,
|
||||
declaredType,
|
||||
ownerId: enclosingClassId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -1103,7 +1103,7 @@ async function runChunkedParseAndResolve(
|
||||
* Post-parse graph analysis: MRO, community detection, process extraction.
|
||||
*
|
||||
* @reads graph (all nodes and relationships from parse + resolve phases)
|
||||
* @writes graph (Community nodes, Process nodes, MEMBER_OF edges, STEP_IN_PROCESS edges, OVERRIDES edges)
|
||||
* @writes graph (Community nodes, Process nodes, MEMBER_OF edges, STEP_IN_PROCESS edges, METHOD_OVERRIDES edges)
|
||||
*/
|
||||
async function runGraphAnalysisPhases(
|
||||
graph: ReturnType<typeof createKnowledgeGraph>,
|
||||
@@ -1126,7 +1126,7 @@ async function runGraphAnalysisPhases(
|
||||
const mroResult = computeMRO(graph);
|
||||
if (isDev && mroResult.entries.length > 0) {
|
||||
console.log(
|
||||
`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities found, ${mroResult.overrideEdges} OVERRIDES edges`,
|
||||
`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities, ${mroResult.overrideEdges} METHOD_OVERRIDES, ${mroResult.methodImplementsEdges} METHOD_IMPLEMENTS`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export const TYPESCRIPT_QUERIES = `
|
||||
(class_declaration
|
||||
name: (type_identifier) @name) @definition.class
|
||||
|
||||
(abstract_class_declaration
|
||||
name: (type_identifier) @name) @definition.class
|
||||
|
||||
(interface_declaration
|
||||
name: (type_identifier) @name) @definition.interface
|
||||
|
||||
@@ -24,6 +27,18 @@ export const TYPESCRIPT_QUERIES = `
|
||||
(method_definition
|
||||
name: (property_identifier) @name) @definition.method
|
||||
|
||||
; ES2022 #private methods (private_property_identifier not matched by property_identifier)
|
||||
(method_definition
|
||||
name: (private_property_identifier) @name) @definition.method
|
||||
|
||||
; Abstract method signatures in abstract classes
|
||||
(abstract_method_signature
|
||||
name: (property_identifier) @name) @definition.method
|
||||
|
||||
; Interface method signatures
|
||||
(method_signature
|
||||
name: (property_identifier) @name) @definition.method
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
@@ -145,6 +160,10 @@ export const JAVASCRIPT_QUERIES = `
|
||||
(method_definition
|
||||
name: (property_identifier) @name) @definition.method
|
||||
|
||||
; ES2022 #private methods
|
||||
(method_definition
|
||||
name: (private_property_identifier) @name) @definition.method
|
||||
|
||||
(lexical_declaration
|
||||
(variable_declarator
|
||||
name: (identifier) @name
|
||||
@@ -330,6 +349,7 @@ export const JAVA_QUERIES = `
|
||||
; Calls
|
||||
(method_invocation name: (identifier) @call.name) @call
|
||||
(method_invocation object: (_) name: (identifier) @call.name) @call
|
||||
(method_reference) @call
|
||||
|
||||
; Constructor calls: new Foo()
|
||||
(object_creation_expression type: (type_identifier) @call.name) @call
|
||||
@@ -615,6 +635,7 @@ export const CSHARP_QUERIES = `
|
||||
export const RUST_QUERIES = `
|
||||
; Functions & Items
|
||||
(function_item name: (identifier) @name) @definition.function
|
||||
(function_signature_item name: (identifier) @name) @definition.function
|
||||
(struct_item name: (type_identifier) @name) @definition.struct
|
||||
(enum_item name: (type_identifier) @name) @definition.enum
|
||||
(trait_item name: (type_identifier) @name) @definition.trait
|
||||
@@ -1105,6 +1126,13 @@ export const DART_QUERIES = `
|
||||
value: (identifier) @call.name
|
||||
(selector (argument_part))) @call
|
||||
|
||||
; ── Calls: member calls in variable assignments (var x = obj.method()) ──────
|
||||
(initialized_variable_definition
|
||||
(selector
|
||||
(unconditional_assignable_selector
|
||||
(identifier) @call.name))
|
||||
(selector (argument_part))) @call
|
||||
|
||||
; ── Re-exports (export 'foo.dart') ───────────────────────────────────────────
|
||||
(import_or_export
|
||||
(library_export
|
||||
@@ -1163,5 +1191,6 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
|
||||
[SupportedLanguages.Ruby]: RUBY_QUERIES,
|
||||
[SupportedLanguages.Swift]: SWIFT_QUERIES,
|
||||
[SupportedLanguages.Dart]: DART_QUERIES,
|
||||
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
|
||||
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
type SyntaxNode,
|
||||
FUNCTION_NODE_TYPES,
|
||||
extractFunctionName,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
genericFuncName,
|
||||
} from './utils/ast-helpers.js';
|
||||
import { CALL_EXPRESSION_TYPES } from './utils/call-analysis.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
@@ -132,6 +132,7 @@ const lookupInEnv = (
|
||||
callNode: SyntaxNode,
|
||||
patternOverrides?: PatternOverrides,
|
||||
enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null,
|
||||
extractFunctionNameHook?: (n: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null,
|
||||
): string | undefined => {
|
||||
// Self/this receiver: resolve to enclosing class name via AST walk
|
||||
if (varName === 'self' || varName === 'this' || varName === '$this') {
|
||||
@@ -145,7 +146,11 @@ const lookupInEnv = (
|
||||
}
|
||||
|
||||
// Determine the enclosing function scope for the call
|
||||
const scopeKey = findEnclosingScopeKey(callNode, enclosingFunctionFinder);
|
||||
const scopeKey = findEnclosingScopeKey(
|
||||
callNode,
|
||||
enclosingFunctionFinder,
|
||||
extractFunctionNameHook,
|
||||
);
|
||||
|
||||
// Check position-indexed pattern overrides first (e.g., Kotlin when/is smart casts).
|
||||
// These take priority over flat scopeEnv because they represent per-branch narrowing.
|
||||
@@ -361,11 +366,12 @@ const extractParentClassFromNode = (classNode: SyntaxNode): string | undefined =
|
||||
const findEnclosingScopeKey = (
|
||||
node: SyntaxNode,
|
||||
enclosingFunctionFinder?: (n: SyntaxNode) => { funcName: string; label: NodeLabel } | null,
|
||||
extractFunctionNameHook?: (n: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null,
|
||||
): string | undefined => {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (FUNCTION_NODE_TYPES.has(current.type)) {
|
||||
const { funcName } = extractFunctionName(current);
|
||||
const funcName = extractFunctionNameHook?.(current)?.funcName ?? genericFuncName(current);
|
||||
if (funcName) return `${funcName}@${current.startIndex}`;
|
||||
}
|
||||
// Language-specific hook (e.g., Dart function_body → sibling function_signature)
|
||||
@@ -621,7 +627,15 @@ const resolveMethodReturnType = (
|
||||
parentMap?: ReadonlyMap<string, readonly string[]>,
|
||||
): string | undefined => {
|
||||
if (!symbolTable) return undefined;
|
||||
const receiverType = scopeEnv.get(receiver);
|
||||
let receiverType = scopeEnv.get(receiver);
|
||||
// When substituteThisReceiver replaced $this/self with the enclosing class name,
|
||||
// the receiver IS the type — look it up directly as a class name.
|
||||
if (!receiverType) {
|
||||
const lookup =
|
||||
getClassDefs ??
|
||||
((name: string) => symbolTable.lookupFuzzy(name).filter((d) => CLASS_LIKE_TYPES.has(d.type)));
|
||||
if (lookup(receiver).length > 0) receiverType = receiver;
|
||||
}
|
||||
if (!receiverType) return undefined;
|
||||
const lookup =
|
||||
getClassDefs ??
|
||||
@@ -765,6 +779,11 @@ export interface BuildTypeEnvOptions {
|
||||
enclosingFunctionFinder?: (
|
||||
ancestorNode: SyntaxNode,
|
||||
) => { funcName: string; label: NodeLabel } | null;
|
||||
/** Language-specific function name extraction from an AST node.
|
||||
* Replaces the generic name-field lookup for languages with non-standard
|
||||
* AST structures (C/C++ declarator unwrapping, Swift init/deinit, etc.).
|
||||
* When null is returned or not provided, falls back to node.childForFieldName('name')?.text. */
|
||||
extractFunctionName?: (node: SyntaxNode) => { funcName: string | null; label: NodeLabel } | null;
|
||||
}
|
||||
|
||||
/** Seed cross-file type bindings into the file scope.
|
||||
@@ -794,6 +813,7 @@ export const buildTypeEnv = (
|
||||
|
||||
const symbolTable = options?.symbolTable;
|
||||
const parentMap = options?.parentMap;
|
||||
const extractFuncNameHook = options?.extractFunctionName;
|
||||
const env: TypeEnv = new Map();
|
||||
const patternOverrides: PatternOverrides = new Map();
|
||||
// Phase P: maps `scope\0varName` → constructor type when a declaration has BOTH
|
||||
@@ -955,47 +975,19 @@ export const buildTypeEnv = (
|
||||
// This decouples type node capture from scopeEnv success — container types
|
||||
// (User[], []User, List[User]) that fail extractSimpleTypeName still get
|
||||
// their AST type node recorded for Strategy 1 for-loop resolution.
|
||||
// Try direct extraction first (works for Go var_spec, Python assignment, Rust let_declaration).
|
||||
// Try direct type field first, then unwrap wrapper nodes (C# field_declaration,
|
||||
// local_declaration_statement wrap their type inside a variable_declaration child).
|
||||
let typeNode = node.childForFieldName('type');
|
||||
//
|
||||
// Prefer language-specific locator when provided (keeps buildTypeEnv generic),
|
||||
// then fall back to a small set of safe, cross-grammar heuristics.
|
||||
let typeNode =
|
||||
config.getDeclarationTypeNode?.(node) ?? node.childForFieldName('type') ?? null;
|
||||
// Fallback: some grammars wrap type annotations in a `type_annotation` child
|
||||
// instead of exposing a named `type` field on the declaration node.
|
||||
if (!typeNode) {
|
||||
// C# field_declaration / local_declaration_statement wrap type inside variable_declaration.
|
||||
// Use manual loop instead of namedChildren.find() to avoid array allocation on hot path.
|
||||
let wrapped = node.childForFieldName('declaration');
|
||||
if (!wrapped) {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c?.type === 'variable_declaration') {
|
||||
wrapped = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wrapped) {
|
||||
typeNode = wrapped.childForFieldName('type');
|
||||
// Kotlin: variable_declaration stores the type as user_type / nullable_type
|
||||
// child rather than a named 'type' field.
|
||||
if (!typeNode) {
|
||||
for (let i = 0; i < wrapped.namedChildCount; i++) {
|
||||
const c = wrapped.namedChild(i);
|
||||
if (c && (c.type === 'user_type' || c.type === 'nullable_type')) {
|
||||
typeNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Swift: property_declaration has type_annotation as a direct child (not a 'type' field).
|
||||
// Extract the inner type node (array_type, user_type, etc.) for declarationTypeNodes.
|
||||
if (!typeNode) {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c?.type === 'type_annotation') {
|
||||
// Use the inner type (array_type, user_type) rather than the annotation wrapper
|
||||
typeNode = c.firstNamedChild ?? c;
|
||||
break;
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c?.type === 'type_annotation') {
|
||||
typeNode = c.firstNamedChild ?? c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1085,7 +1077,7 @@ export const buildTypeEnv = (
|
||||
// Detect scope boundaries (function/method definitions)
|
||||
let scope = currentScope;
|
||||
if (FUNCTION_NODE_TYPES.has(node.type)) {
|
||||
const { funcName } = extractFunctionName(node);
|
||||
const funcName = extractFuncNameHook?.(node)?.funcName ?? genericFuncName(node);
|
||||
if (funcName) scope = `${funcName}@${node.startIndex}`;
|
||||
}
|
||||
|
||||
@@ -1234,7 +1226,14 @@ export const buildTypeEnv = (
|
||||
|
||||
return {
|
||||
lookup: (varName, callNode) =>
|
||||
lookupInEnv(env, varName, callNode, patternOverrides, options?.enclosingFunctionFinder),
|
||||
lookupInEnv(
|
||||
env,
|
||||
varName,
|
||||
callNode,
|
||||
patternOverrides,
|
||||
options?.enclosingFunctionFinder,
|
||||
extractFuncNameHook,
|
||||
),
|
||||
constructorBindings: bindings,
|
||||
fileScope: () => env.get(FILE_SCOPE) ?? EMPTY_FILE_SCOPE,
|
||||
allScopes: () => env as ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
|
||||
@@ -559,6 +559,24 @@ const inferLiteralType: LiteralTypeInferrer = (node) => {
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
getDeclarationTypeNode: (node) => {
|
||||
// C# field_declaration / local_declaration_statement wrap type inside variable_declaration.
|
||||
// Prefer the wrapper node's `type` field when present.
|
||||
const direct = node.childForFieldName('type');
|
||||
if (direct) return direct;
|
||||
|
||||
const wrapped =
|
||||
node.childForFieldName('declaration') ??
|
||||
(() => {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c?.type === 'variable_declaration') return c;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
return wrapped?.childForFieldName('type') ?? null;
|
||||
},
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set([
|
||||
'is_pattern_expression',
|
||||
|
||||
@@ -850,6 +850,20 @@ const extractKotlinPatternBinding: PatternBindingExtractor = (
|
||||
export const kotlinTypeConfig: LanguageTypeConfig = {
|
||||
allowPatternBindingOverwrite: true,
|
||||
declarationNodeTypes: KOTLIN_DECLARATION_NODE_TYPES,
|
||||
getDeclarationTypeNode: (node) => {
|
||||
// Kotlin property_declaration wraps the actual declaration in variable_declaration.
|
||||
// The type is commonly a user_type / nullable_type child (positional, not 'type' field).
|
||||
const varDecl =
|
||||
node.type === 'property_declaration' ? findChild(node, 'variable_declaration') : node;
|
||||
if (varDecl) {
|
||||
return (
|
||||
varDecl.childForFieldName('type') ??
|
||||
findChild(varDecl, 'user_type') ??
|
||||
findChild(varDecl, 'nullable_type')
|
||||
);
|
||||
}
|
||||
return node.childForFieldName('type') ?? findChild(node, 'user_type') ?? null;
|
||||
},
|
||||
forLoopNodeTypes: KOTLIN_FOR_LOOP_NODE_TYPES,
|
||||
patternBindingNodeTypes: new Set(['type_test', 'equality_expression']),
|
||||
extractDeclaration: extractKotlinDeclaration,
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
InitializerExtractor,
|
||||
ClassNameLookup,
|
||||
ConstructorBindingScanner,
|
||||
ReturnTypeExtractor,
|
||||
PendingAssignmentExtractor,
|
||||
ForLoopExtractor,
|
||||
} from './types.js';
|
||||
@@ -337,60 +336,6 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** Regex to extract PHPDoc @return annotations: `@return User` */
|
||||
const PHPDOC_RETURN_RE = /@return\s+(\S+)/;
|
||||
|
||||
/**
|
||||
* Normalize a PHPDoc return type for storage in the SymbolTable.
|
||||
* Unlike normalizePhpType (which strips User[] → User for scopeEnv), this preserves
|
||||
* array notation so lookupRawReturnType can extract element types for for-loop resolution.
|
||||
* \App\Models\User[] → User[]
|
||||
* ?User → User
|
||||
* Collection<User> → Collection<User> (preserved for extractElementTypeFromString)
|
||||
*/
|
||||
const normalizePhpReturnType = (raw: string): string | undefined => {
|
||||
// Strip nullable prefix: ?User[] → User[]
|
||||
let type = raw.startsWith('?') ? raw.slice(1) : raw;
|
||||
// Strip union with null/false/void: User[]|null → User[]
|
||||
const parts = type
|
||||
.split('|')
|
||||
.filter((p) => p !== 'null' && p !== 'false' && p !== 'void' && p !== 'mixed');
|
||||
if (parts.length !== 1) return undefined;
|
||||
type = parts[0];
|
||||
// Strip namespace: \App\Models\User[] → User[]
|
||||
const segments = type.split('\\');
|
||||
type = segments[segments.length - 1];
|
||||
// Skip uninformative types
|
||||
if (
|
||||
type === 'mixed' ||
|
||||
type === 'void' ||
|
||||
type === 'self' ||
|
||||
type === 'static' ||
|
||||
type === 'object' ||
|
||||
type === 'array'
|
||||
)
|
||||
return undefined;
|
||||
if (/^\w+(\[\])?$/.test(type) || /^\w+\s*</.test(type)) return type;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract return type from PHPDoc `@return Type` annotation preceding a method.
|
||||
* Walks backwards through preceding siblings looking for comment nodes.
|
||||
* Preserves array notation (e.g., User[]) for for-loop element type extraction.
|
||||
*/
|
||||
const extractReturnType: ReturnTypeExtractor = (node) => {
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = PHPDOC_RETURN_RE.exec(sibling.text);
|
||||
if (match) return normalizePhpReturnType(match[1]);
|
||||
} else if (sibling.isNamed && !SKIP_NODE_TYPES.has(sibling.type)) break;
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** PHP: $alias = $user → assignment_expression with variable_name left/right.
|
||||
* PHP TypeEnv stores variables WITH $ prefix ($user → User), so we keep $ in lhs/rhs. */
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
@@ -605,7 +550,6 @@ export const typeConfig: LanguageTypeConfig = {
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
extractReturnType,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
TypeBindingExtractor,
|
||||
InitializerExtractor,
|
||||
ConstructorBindingScanner,
|
||||
ReturnTypeExtractor,
|
||||
PendingAssignmentExtractor,
|
||||
ForLoopExtractor,
|
||||
} from './types.js';
|
||||
@@ -43,9 +42,6 @@ const YARD_PARAM_RE = /@param\s+(\w+)\s+\[([^\]]+)\]/g;
|
||||
/** Alternate YARD order: `@param [Type] name` */
|
||||
const YARD_PARAM_ALT_RE = /@param\s+\[([^\]]+)\]\s+(\w+)/g;
|
||||
|
||||
/** Regex to extract @return annotations: `@return [Type]` */
|
||||
const YARD_RETURN_RE = /@return\s+\[([^\]]+)\]/;
|
||||
|
||||
/**
|
||||
* Extract the simple type name from a YARD type string.
|
||||
* Handles:
|
||||
@@ -229,35 +225,6 @@ const extractInitializer: InitializerExtractor = (node, env, classNames): void =
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract return type from YARD `@return [Type]` annotation preceding a method.
|
||||
* Reuses the same comment-walking strategy as collectYardParams: try direct
|
||||
* siblings first, fall back to parent (body_statement) siblings for class methods.
|
||||
*/
|
||||
const extractReturnType: ReturnTypeExtractor = (node) => {
|
||||
const search = (startNode: SyntaxNode): string | undefined => {
|
||||
let sibling = startNode.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = YARD_RETURN_RE.exec(sibling.text);
|
||||
if (match) return extractYardTypeName(match[1]);
|
||||
} else if (sibling.isNamed) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const result = search(node);
|
||||
if (result) return result;
|
||||
|
||||
if (node.parent?.type === 'body_statement') {
|
||||
return search(node.parent);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ruby constructor binding scanner: captures both `user = User.new` and
|
||||
* plain call assignments like `user = get_user()`.
|
||||
@@ -452,7 +419,6 @@ export const typeConfig: LanguageTypeConfig = {
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
extractReturnType,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
};
|
||||
|
||||
@@ -496,6 +496,17 @@ function extractSwiftElementTypeFromTypeNode(typeNode: SyntaxNode): string | und
|
||||
|
||||
export const typeConfig: LanguageTypeConfig = {
|
||||
declarationNodeTypes: DECLARATION_NODE_TYPES,
|
||||
getDeclarationTypeNode: (node) => {
|
||||
// Swift: many declarations store type as a type_annotation child (not a 'type' field).
|
||||
// Prefer a direct 'type' field if present, else unwrap type_annotation to its inner type.
|
||||
const direct = node.childForFieldName('type');
|
||||
if (direct) return direct;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c?.type === 'type_annotation') return c.firstNamedChild ?? c;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
forLoopNodeTypes: FOR_LOOP_NODE_TYPES,
|
||||
extractDeclaration,
|
||||
extractParameter,
|
||||
|
||||
@@ -6,6 +6,11 @@ export type TypeBindingExtractor = (node: SyntaxNode, env: Map<string, string>)
|
||||
/** Extracts type bindings from a parameter node into the env map */
|
||||
export type ParameterExtractor = (node: SyntaxNode, env: Map<string, string>) => void;
|
||||
|
||||
/** Optionally locates the type-annotation AST node for a declaration node.
|
||||
* Used by buildTypeEnv to populate declarationTypeNodes and constructorTypeMap.
|
||||
* If absent, buildTypeEnv falls back to generic heuristics (childForFieldName('type'), etc). */
|
||||
export type DeclarationTypeNodeLocator = (node: SyntaxNode) => SyntaxNode | null;
|
||||
|
||||
/** Minimal interface for checking whether a name is a known class/struct.
|
||||
* Narrower than ReadonlySet — only `.has()` is used by extractors. */
|
||||
export type ClassNameLookup = { has(name: string): boolean };
|
||||
@@ -25,11 +30,6 @@ export type ConstructorBindingScanner = (
|
||||
node: SyntaxNode,
|
||||
) => { varName: string; calleeName: string; receiverClassName?: string } | undefined;
|
||||
|
||||
/** Extracts a return type string from a method/function definition node.
|
||||
* Used for languages where return types are expressed in comments (e.g. YARD @return [Type])
|
||||
* rather than in AST fields. Returns undefined if no return type can be determined. */
|
||||
export type ReturnTypeExtractor = (node: SyntaxNode) => string | undefined;
|
||||
|
||||
/** Infer the type name of a literal AST node for overload disambiguation.
|
||||
* Returns the canonical type name (e.g. 'int', 'String', 'boolean') or undefined
|
||||
* for non-literal nodes. Only used when resolveCallTarget has multiple candidates
|
||||
@@ -142,6 +142,9 @@ export interface LanguageTypeConfig {
|
||||
readonly allowPatternBindingOverwrite?: boolean;
|
||||
/** Node types that represent typed declarations for this language */
|
||||
declarationNodeTypes: ReadonlySet<string>;
|
||||
/** Optional: language-specific way to find a declaration's type-annotation node.
|
||||
* Prefer providing this for grammars where the type is wrapped (e.g., C#, Kotlin, Swift). */
|
||||
getDeclarationTypeNode?: DeclarationTypeNodeLocator;
|
||||
/** AST node types for for-each/for-in statements with explicit element types. */
|
||||
forLoopNodeTypes?: ReadonlySet<string>;
|
||||
/** Optional allowlist of AST node types on which extractPatternBinding should run.
|
||||
@@ -162,9 +165,6 @@ export interface LanguageTypeConfig {
|
||||
* Called on every AST node during buildTypeEnv walk; returns undefined for non-matches.
|
||||
* The callee binding is unverified — the caller must confirm against the SymbolTable. */
|
||||
scanConstructorBinding?: ConstructorBindingScanner;
|
||||
/** Extract return type from comment-based annotations (e.g. YARD @return [Type]).
|
||||
* Called as fallback when extractMethodSignature finds no AST-based return type. */
|
||||
extractReturnType?: ReturnTypeExtractor;
|
||||
/** Extract loop variable → type binding from a for-each AST node. */
|
||||
extractForLoopBinding?: ForLoopExtractor;
|
||||
/** Extract pending assignment for Tier 2 propagation.
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
InitializerExtractor,
|
||||
ClassNameLookup,
|
||||
ConstructorBindingScanner,
|
||||
ReturnTypeExtractor,
|
||||
PendingAssignmentExtractor,
|
||||
PendingAssignment,
|
||||
ForLoopExtractor,
|
||||
@@ -198,44 +197,6 @@ const scanConstructorBinding: ConstructorBindingScanner = (node) => {
|
||||
return { varName: nameNode.text, calleeName };
|
||||
};
|
||||
|
||||
/** Regex to extract @returns or @return from JSDoc comments: `@returns {Type}` */
|
||||
const JSDOC_RETURN_RE = /@returns?\s*\{([^}]+)\}/;
|
||||
|
||||
/**
|
||||
* Minimal sanitization for JSDoc return types — preserves generic wrappers
|
||||
* (e.g. `Promise<User>`) so that extractReturnTypeName in call-processor
|
||||
* can apply WRAPPER_GENERICS unwrapping. Unlike normalizeJsDocType (which
|
||||
* strips generics), this only strips JSDoc-specific syntax markers.
|
||||
*/
|
||||
const sanitizeReturnType = (raw: string): string | undefined => {
|
||||
let type = raw.trim();
|
||||
// Strip JSDoc nullable/non-nullable prefixes: ?User → User, !User → User
|
||||
if (type.startsWith('?') || type.startsWith('!')) type = type.slice(1);
|
||||
// Strip module: prefix — module:models.User → models.User
|
||||
if (type.startsWith('module:')) type = type.slice(7);
|
||||
// Reject unions (ambiguous)
|
||||
if (type.includes('|')) return undefined;
|
||||
if (!type) return undefined;
|
||||
return type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract return type from JSDoc `@returns {Type}` or `@return {Type}` annotation
|
||||
* preceding a function/method definition. Walks backwards through preceding siblings
|
||||
* looking for comment nodes containing the annotation.
|
||||
*/
|
||||
const extractReturnType: ReturnTypeExtractor = (node) => {
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling) {
|
||||
if (sibling.type === 'comment') {
|
||||
const match = JSDOC_RETURN_RE.exec(sibling.text);
|
||||
if (match) return sanitizeReturnType(match[1]);
|
||||
} else if (sibling.isNamed && sibling.type !== 'decorator') break;
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const FOR_LOOP_NODE_TYPES: ReadonlySet<string> = new Set(['for_in_statement']);
|
||||
|
||||
/** TS function/method node types that carry a parameters list. */
|
||||
@@ -480,8 +441,40 @@ const extractForLoopBinding: ForLoopExtractor = (
|
||||
if (loopVarName) scopeEnv.set(loopVarName, elementType);
|
||||
};
|
||||
|
||||
/** Collect fieldAccess items from an object_pattern's destructured properties. */
|
||||
const collectDestructuredFields = (
|
||||
nameNode: SyntaxNode,
|
||||
receiver: string,
|
||||
scopeEnv: ReadonlyMap<string, string>,
|
||||
): PendingAssignment[] => {
|
||||
const items: PendingAssignment[] = [];
|
||||
for (let j = 0; j < nameNode.namedChildCount; j++) {
|
||||
const prop = nameNode.namedChild(j);
|
||||
if (!prop) continue;
|
||||
if (prop.type === 'shorthand_property_identifier_pattern') {
|
||||
// `const { name } = obj` → shorthand: varName = fieldName
|
||||
const varName = prop.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
|
||||
}
|
||||
} else if (prop.type === 'pair_pattern') {
|
||||
// `const { address: addr } = obj` → pair_pattern: key=field, value=varName
|
||||
const keyNode = prop.childForFieldName('key');
|
||||
const valNode = prop.childForFieldName('value');
|
||||
if (keyNode && valNode) {
|
||||
const fieldName = keyNode.text;
|
||||
const varName = valNode.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
/** TS/JS: const alias = u → variable_declarator with name/value fields.
|
||||
* Also handles destructuring: `const { a, b } = obj` → N fieldAccess items. */
|
||||
* Also handles destructuring: `const { a, b } = obj` and `const { a } = fn()` → N fieldAccess items. */
|
||||
const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) => {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
@@ -490,34 +483,50 @@ const extractPendingAssignment: PendingAssignmentExtractor = (node, scopeEnv) =>
|
||||
const valueNode = child.childForFieldName('value');
|
||||
if (!nameNode || !valueNode) continue;
|
||||
|
||||
// Object destructuring: `const { address, name } = user`
|
||||
// Emits N fieldAccess items — one per destructured binding.
|
||||
// Object destructuring from identifier: `const { address, name } = user`
|
||||
if (nameNode.type === 'object_pattern' && valueNode.type === 'identifier') {
|
||||
const receiver = valueNode.text;
|
||||
const items: PendingAssignment[] = [];
|
||||
for (let j = 0; j < nameNode.namedChildCount; j++) {
|
||||
const prop = nameNode.namedChild(j);
|
||||
if (!prop) continue;
|
||||
if (prop.type === 'shorthand_property_identifier_pattern') {
|
||||
// `const { name } = user` → shorthand: varName = fieldName
|
||||
const varName = prop.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: varName });
|
||||
}
|
||||
} else if (prop.type === 'pair_pattern') {
|
||||
// `const { address: addr } = user` → pair_pattern: key=field, value=varName
|
||||
const keyNode = prop.childForFieldName('key');
|
||||
const valNode = prop.childForFieldName('value');
|
||||
if (keyNode && valNode) {
|
||||
const fieldName = keyNode.text;
|
||||
const varName = valNode.text;
|
||||
if (!scopeEnv.has(varName)) {
|
||||
items.push({ kind: 'fieldAccess', lhs: varName, receiver, field: fieldName });
|
||||
const items = collectDestructuredFields(nameNode, valueNode.text, scopeEnv);
|
||||
if (items.length > 0) return items;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Object destructuring from call/await: `const { x } = fn()` or `const { x } = await fn()`
|
||||
// Emits a synthetic callResult + N fieldAccess items resolved via fixpoint iteration.
|
||||
if (nameNode.type === 'object_pattern') {
|
||||
const callNode = unwrapAwait(valueNode);
|
||||
if (callNode?.type === 'call_expression') {
|
||||
const funcNode = callNode.childForFieldName('function');
|
||||
if (funcNode) {
|
||||
let syntheticVar: string | undefined;
|
||||
let leadItem: PendingAssignment | undefined;
|
||||
|
||||
if (funcNode.type === 'identifier') {
|
||||
syntheticVar = `__destr_${funcNode.text}_${callNode.startIndex}`;
|
||||
leadItem = { kind: 'callResult', lhs: syntheticVar, callee: funcNode.text };
|
||||
} else if (funcNode.type === 'member_expression') {
|
||||
const obj = funcNode.childForFieldName('object');
|
||||
const prop = funcNode.childForFieldName('property');
|
||||
if (
|
||||
obj &&
|
||||
prop?.type === 'property_identifier' &&
|
||||
(obj.type === 'identifier' || obj.type === 'this')
|
||||
) {
|
||||
syntheticVar = `__destr_${prop.text}_${callNode.startIndex}`;
|
||||
leadItem = {
|
||||
kind: 'methodCallResult',
|
||||
lhs: syntheticVar,
|
||||
receiver: obj.text,
|
||||
method: prop.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (syntheticVar && leadItem) {
|
||||
const fieldItems = collectDestructuredFields(nameNode, syntheticVar, scopeEnv);
|
||||
if (fieldItems.length > 0) return [leadItem, ...fieldItems];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items.length > 0) return items;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -694,7 +703,6 @@ export const typeConfig: LanguageTypeConfig = {
|
||||
extractParameter,
|
||||
extractInitializer,
|
||||
scanConstructorBinding,
|
||||
extractReturnType,
|
||||
extractForLoopBinding,
|
||||
extractPendingAssignment,
|
||||
extractPatternBinding,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type Parser from 'tree-sitter';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { extractSimpleTypeName } from '../type-extractors/shared.js';
|
||||
|
||||
/** Tree-sitter AST node. Re-exported for use across ingestion modules. */
|
||||
export type SyntaxNode = Parser.SyntaxNode;
|
||||
@@ -48,7 +47,13 @@ export const getDefinitionNodeFromCaptures = (
|
||||
|
||||
/**
|
||||
* Node types that represent function/method definitions across languages.
|
||||
* Used to find the enclosing function for a call site.
|
||||
* Used by parent-walk in call-processor, parse-worker, and type-env to detect
|
||||
* enclosing function scope boundaries.
|
||||
*
|
||||
* INVARIANT: This set MUST be a superset of every language's
|
||||
* MethodExtractionConfig.methodNodeTypes. When adding a new node type to a
|
||||
* MethodExtractor config, add it here too — otherwise enclosing-function
|
||||
* resolution will silently miss that node type during parent-walks.
|
||||
*/
|
||||
export const FUNCTION_NODE_TYPES = new Set([
|
||||
// TypeScript/JavaScript
|
||||
@@ -92,18 +97,13 @@ export const FUNCTION_NODE_TYPES = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node types for standard function declarations that need C/C++ declarator handling.
|
||||
* Used by extractFunctionName to determine how to extract the function name.
|
||||
* AST node types that represent a class-like container (for HAS_METHOD edge extraction).
|
||||
*
|
||||
* INVARIANT: When a language config adds a new node type to `typeDeclarationNodes`,
|
||||
* that type must also be added here AND to `CONTAINER_TYPE_TO_LABEL` below,
|
||||
* otherwise `findEnclosingClassNode` won't recognize it and methods may get
|
||||
* orphaned HAS_METHOD edges or incorrect labels.
|
||||
*/
|
||||
export const FUNCTION_DECLARATION_TYPES = new Set([
|
||||
'function_declaration',
|
||||
'function_definition',
|
||||
'async_function_declaration',
|
||||
'generator_function_declaration',
|
||||
'function_item',
|
||||
]);
|
||||
|
||||
/** AST node types that represent a class-like container (for HAS_METHOD edge extraction) */
|
||||
export const CLASS_CONTAINER_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'abstract_class_declaration',
|
||||
@@ -118,10 +118,16 @@ export const CLASS_CONTAINER_TYPES = new Set([
|
||||
'enum_item',
|
||||
'class_definition',
|
||||
'trait_declaration',
|
||||
// PHP
|
||||
'enum_declaration',
|
||||
'protocol_declaration',
|
||||
// Dart
|
||||
'mixin_declaration',
|
||||
'extension_declaration',
|
||||
// Ruby
|
||||
'class',
|
||||
'module',
|
||||
'singleton_class', // Ruby: class << self
|
||||
// Kotlin
|
||||
'object_declaration',
|
||||
'companion_object',
|
||||
@@ -140,28 +146,18 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
|
||||
struct_item: 'Struct',
|
||||
enum_item: 'Enum',
|
||||
trait_declaration: 'Trait',
|
||||
enum_declaration: 'Enum',
|
||||
record_declaration: 'Record',
|
||||
protocol_declaration: 'Interface',
|
||||
mixin_declaration: 'Mixin',
|
||||
extension_declaration: 'Extension',
|
||||
class: 'Class',
|
||||
module: 'Module',
|
||||
singleton_class: 'Class', // Ruby: class << self inherits enclosing class name
|
||||
object_declaration: 'Class',
|
||||
companion_object: 'Class',
|
||||
};
|
||||
|
||||
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
|
||||
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
|
||||
* Returns true when the captured definition node has a class_body ancestor. */
|
||||
export function isKotlinClassMethod(
|
||||
captureNode: { parent?: SyntaxNode | null } | null | undefined,
|
||||
): boolean {
|
||||
let ancestor = captureNode?.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_body') return true;
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the graph node label from a tree-sitter capture map.
|
||||
* Handles language-specific reclassification via the provider's labelOverride hook
|
||||
@@ -206,33 +202,42 @@ export function getLabelFromCaptures(
|
||||
return 'CodeElement';
|
||||
}
|
||||
|
||||
/** Walk up AST to find enclosing class/struct/interface/impl, return its generateId or null.
|
||||
/** Enclosing class info: both the generated node ID and the bare class name. */
|
||||
export interface EnclosingClassInfo {
|
||||
classId: string; // e.g. "Class:animal.dart:Animal"
|
||||
className: string; // e.g. "Animal"
|
||||
}
|
||||
|
||||
/** Walk up AST to find enclosing class/struct/interface/impl, return its ID and name.
|
||||
* For Go method_declaration nodes, extracts receiver type (e.g. `func (u *User) Save()` → User struct). */
|
||||
export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
|
||||
export const findEnclosingClassInfo = (
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
): EnclosingClassInfo | null => {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
// Go: method_declaration has a receiver parameter with the struct type
|
||||
if (current.type === 'method_declaration') {
|
||||
const receiver = current.childForFieldName?.('receiver');
|
||||
if (receiver) {
|
||||
// receiver is a parameter_list: (u *User) or (u User)
|
||||
const paramDecl = receiver.namedChildren?.find?.(
|
||||
(c: SyntaxNode) => c.type === 'parameter_declaration',
|
||||
);
|
||||
if (paramDecl) {
|
||||
const typeNode = paramDecl.childForFieldName?.('type');
|
||||
if (typeNode) {
|
||||
// Unwrap pointer_type (*User → User)
|
||||
const inner = typeNode.type === 'pointer_type' ? typeNode.firstNamedChild : typeNode;
|
||||
if (inner && (inner.type === 'type_identifier' || inner.type === 'identifier')) {
|
||||
return generateId('Struct', `${filePath}:${inner.text}`);
|
||||
return {
|
||||
classId: generateId('Struct', `${filePath}:${inner.text}`),
|
||||
className: inner.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Go: type_declaration wrapping a struct_type (type User struct { ... })
|
||||
// field_declaration → field_declaration_list → struct_type → type_spec → type_declaration
|
||||
if (current.type === 'type_declaration') {
|
||||
const typeSpec = current.children?.find((c: SyntaxNode) => c.type === 'type_spec');
|
||||
if (typeSpec) {
|
||||
@@ -241,26 +246,66 @@ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string
|
||||
const nameNode = typeSpec.childForFieldName?.('name');
|
||||
if (nameNode) {
|
||||
const label = typeBody.type === 'struct_type' ? 'Struct' : 'Interface';
|
||||
return generateId(label, `${filePath}:${nameNode.text}`);
|
||||
return {
|
||||
classId: generateId(label, `${filePath}:${nameNode.text}`),
|
||||
className: nameNode.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CLASS_CONTAINER_TYPES.has(current.type)) {
|
||||
// Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for`
|
||||
// NOTE: This impl_item ownership logic is duplicated in rust.ts:extractOwnerName.
|
||||
// If modifying this block, update the other location too.
|
||||
if (current.type === 'impl_item') {
|
||||
const children = current.children ?? [];
|
||||
const forIdx = children.findIndex((c: SyntaxNode) => c.text === 'for');
|
||||
if (forIdx !== -1) {
|
||||
const nameNode = children
|
||||
.slice(forIdx + 1)
|
||||
.find((c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'identifier');
|
||||
.find(
|
||||
(c: SyntaxNode) =>
|
||||
c.type === 'type_identifier' ||
|
||||
c.type === 'scoped_type_identifier' ||
|
||||
c.type === 'identifier',
|
||||
);
|
||||
if (nameNode) {
|
||||
return generateId('Impl', `${filePath}:${nameNode.text}`);
|
||||
return {
|
||||
classId: generateId('Struct', `${filePath}:${nameNode.text}`),
|
||||
className: nameNode.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fall through: plain `impl Struct {}` — use first type_identifier below
|
||||
const firstType = children.find((c: SyntaxNode) => c.type === 'type_identifier');
|
||||
if (firstType) {
|
||||
return {
|
||||
classId: generateId('Impl', `${filePath}:${firstType.text}`),
|
||||
className: firstType.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Ruby singleton_class (class << self): walk up to the enclosing class/module
|
||||
// to inherit its name. singleton_class has no name field — its receiver is
|
||||
// `self` (node type 'self'), not 'identifier' or 'constant'.
|
||||
if (current.type === 'singleton_class') {
|
||||
let ancestor = current.parent;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class' || ancestor.type === 'module') {
|
||||
const classNameNode = ancestor.childForFieldName?.('name');
|
||||
if (classNameNode) {
|
||||
return {
|
||||
classId: generateId('Class', `${filePath}:${classNameNode.text}`),
|
||||
className: classNameNode.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
// No enclosing class/module — skip singleton_class and keep walking up
|
||||
}
|
||||
|
||||
const nameNode =
|
||||
current.childForFieldName?.('name') ??
|
||||
current.children?.find(
|
||||
@@ -271,8 +316,21 @@ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string
|
||||
c.type === 'constant',
|
||||
);
|
||||
if (nameNode) {
|
||||
const label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
|
||||
return generateId(label, `${filePath}:${nameNode.text}`);
|
||||
let label = CONTAINER_TYPE_TO_LABEL[current.type] || 'Class';
|
||||
// Kotlin: class_declaration with an anonymous "interface" keyword child
|
||||
// is actually an interface, not a class. Refine the label to match the
|
||||
// node ID generated from the tree-sitter query capture (@definition.interface).
|
||||
if (
|
||||
current.type === 'class_declaration' &&
|
||||
label === 'Class' &&
|
||||
current.children?.some((c: SyntaxNode) => c.type === 'interface')
|
||||
) {
|
||||
label = 'Interface';
|
||||
}
|
||||
return {
|
||||
classId: generateId(label, `${filePath}:${nameNode.text}`),
|
||||
className: nameNode.text,
|
||||
};
|
||||
}
|
||||
}
|
||||
current = current.parent;
|
||||
@@ -280,6 +338,11 @@ export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Convenience wrapper: returns just the class ID string (backward compat). */
|
||||
export const findEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
|
||||
return findEnclosingClassInfo(node, filePath)?.classId ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find a child of `childType` within a sibling node of `siblingType`.
|
||||
* Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling.
|
||||
@@ -301,547 +364,49 @@ export const findSiblingChild = (
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract function name and label from a function_definition or similar AST node.
|
||||
* Handles C/C++ qualified_identifier (ClassName::MethodName) and other language patterns.
|
||||
*/
|
||||
export const extractFunctionName = (
|
||||
node: SyntaxNode,
|
||||
): { funcName: string | null; label: NodeLabel } => {
|
||||
let funcName: string | null = null;
|
||||
let label: NodeLabel = 'Function';
|
||||
|
||||
// Swift init/deinit
|
||||
if (node.type === 'init_declaration' || node.type === 'deinit_declaration') {
|
||||
return {
|
||||
funcName: node.type === 'init_declaration' ? 'init' : 'deinit',
|
||||
label: 'Constructor',
|
||||
};
|
||||
/** Generic name extraction from a function-like AST node.
|
||||
* Tries `node.childForFieldName('name')?.text`, then scans children for
|
||||
* `identifier` / `property_identifier` / `simple_identifier`. */
|
||||
export const genericFuncName = (node: SyntaxNode): string | null => {
|
||||
const nameField = node.childForFieldName?.('name');
|
||||
if (nameField) return nameField.text;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (
|
||||
c?.type === 'identifier' ||
|
||||
c?.type === 'property_identifier' ||
|
||||
c?.type === 'simple_identifier'
|
||||
)
|
||||
return c.text;
|
||||
}
|
||||
|
||||
if (FUNCTION_DECLARATION_TYPES.has(node.type)) {
|
||||
// C/C++: function_definition -> [pointer_declarator ->] function_declarator -> qualified_identifier/identifier
|
||||
// Unwrap pointer_declarator / reference_declarator wrappers to reach function_declarator
|
||||
let declarator = node.childForFieldName?.('declarator');
|
||||
if (!declarator) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_declarator') {
|
||||
declarator = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (
|
||||
declarator &&
|
||||
(declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator')
|
||||
) {
|
||||
let nextDeclarator = declarator.childForFieldName?.('declarator');
|
||||
if (!nextDeclarator) {
|
||||
for (let i = 0; i < declarator.childCount; i++) {
|
||||
const c = declarator.child(i);
|
||||
if (
|
||||
c?.type === 'function_declarator' ||
|
||||
c?.type === 'pointer_declarator' ||
|
||||
c?.type === 'reference_declarator'
|
||||
) {
|
||||
nextDeclarator = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
declarator = nextDeclarator;
|
||||
}
|
||||
if (declarator) {
|
||||
let innerDeclarator = declarator.childForFieldName?.('declarator');
|
||||
if (!innerDeclarator) {
|
||||
for (let i = 0; i < declarator.childCount; i++) {
|
||||
const c = declarator.child(i);
|
||||
if (
|
||||
c?.type === 'qualified_identifier' ||
|
||||
c?.type === 'identifier' ||
|
||||
c?.type === 'field_identifier' ||
|
||||
c?.type === 'parenthesized_declarator'
|
||||
) {
|
||||
innerDeclarator = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (innerDeclarator?.type === 'qualified_identifier') {
|
||||
let nameNode = innerDeclarator.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nameNode?.text) {
|
||||
funcName = nameNode.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (
|
||||
innerDeclarator?.type === 'identifier' ||
|
||||
innerDeclarator?.type === 'field_identifier'
|
||||
) {
|
||||
// field_identifier is used for method names inside C++ class bodies
|
||||
funcName = innerDeclarator.text;
|
||||
if (innerDeclarator.type === 'field_identifier') label = 'Method';
|
||||
} else if (innerDeclarator?.type === 'parenthesized_declarator') {
|
||||
let nestedId: SyntaxNode | null = null;
|
||||
for (let i = 0; i < innerDeclarator.childCount; i++) {
|
||||
const c = innerDeclarator.child(i);
|
||||
if (c?.type === 'qualified_identifier' || c?.type === 'identifier') {
|
||||
nestedId = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nestedId?.type === 'qualified_identifier') {
|
||||
let nameNode = nestedId.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < nestedId.childCount; i++) {
|
||||
const c = nestedId.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nameNode?.text) {
|
||||
funcName = nameNode.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (nestedId?.type === 'identifier') {
|
||||
funcName = nestedId.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for other languages (Kotlin uses simple_identifier, Swift uses simple_identifier)
|
||||
if (!funcName) {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (
|
||||
c?.type === 'identifier' ||
|
||||
c?.type === 'property_identifier' ||
|
||||
c?.type === 'simple_identifier'
|
||||
) {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
}
|
||||
} else if (node.type === 'impl_item') {
|
||||
let funcItem: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_item') {
|
||||
funcItem = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (funcItem) {
|
||||
let nameNode = funcItem.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < funcItem.childCount; i++) {
|
||||
const c = funcItem.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
}
|
||||
} else if (node.type === 'method_definition') {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'property_identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
} else if (node.type === 'method_declaration' || node.type === 'constructor_declaration') {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
} else if (node.type === 'arrow_function' || node.type === 'function_expression') {
|
||||
const parent = node.parent;
|
||||
if (parent?.type === 'variable_declarator') {
|
||||
let nameNode = parent.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < parent.childCount; i++) {
|
||||
const c = parent.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
}
|
||||
} else if (node.type === 'method' || node.type === 'singleton_method') {
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text;
|
||||
label = 'Method';
|
||||
} else if (node.type === 'function_signature') {
|
||||
// Dart: top-level function signatures
|
||||
let nameNode = node.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text ?? null;
|
||||
} else if (node.type === 'method_signature') {
|
||||
// Dart: method_signature wraps function_signature
|
||||
let funcSig: SyntaxNode | null = null;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const c = node.child(i);
|
||||
if (c?.type === 'function_signature') {
|
||||
funcSig = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (funcSig) {
|
||||
let nameNode = funcSig.childForFieldName?.('name');
|
||||
if (!nameNode) {
|
||||
for (let i = 0; i < funcSig.childCount; i++) {
|
||||
const c = funcSig.child(i);
|
||||
if (c?.type === 'identifier') {
|
||||
nameNode = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
funcName = nameNode?.text ?? null;
|
||||
}
|
||||
label = 'Method';
|
||||
}
|
||||
|
||||
return { funcName, label };
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface MethodSignature {
|
||||
parameterCount: number | undefined;
|
||||
/** Number of required (non-optional, non-default) parameters.
|
||||
* Only set when fewer than parameterCount — enables range-based arity filtering.
|
||||
* undefined means all parameters are required (or metadata unavailable). */
|
||||
requiredParameterCount: number | undefined;
|
||||
/** Per-parameter type names extracted via extractSimpleTypeName.
|
||||
* Only populated for languages with method overloading (Java, Kotlin, C#, C++).
|
||||
* undefined (not []) when no types are extractable — avoids empty array allocations. */
|
||||
parameterTypes: string[] | undefined;
|
||||
returnType: string | undefined;
|
||||
}
|
||||
/** AST node types that represent a method definition (for `inferFunctionLabel`). */
|
||||
export const METHOD_LABEL_NODE_TYPES = new Set([
|
||||
'method_definition',
|
||||
'method_declaration',
|
||||
'method',
|
||||
'singleton_method',
|
||||
]);
|
||||
|
||||
/** Argument list node types shared between extractMethodSignature and countCallArguments. */
|
||||
/** AST node types that represent a constructor definition (for `inferFunctionLabel`). */
|
||||
export const CONSTRUCTOR_LABEL_NODE_TYPES = new Set([
|
||||
'constructor_declaration',
|
||||
'compact_constructor_declaration',
|
||||
]);
|
||||
|
||||
/** Infer node label from AST node type for function-like nodes without a provider hook. */
|
||||
export const inferFunctionLabel = (nodeType: string): NodeLabel =>
|
||||
METHOD_LABEL_NODE_TYPES.has(nodeType)
|
||||
? 'Method'
|
||||
: CONSTRUCTOR_LABEL_NODE_TYPES.has(nodeType)
|
||||
? 'Constructor'
|
||||
: 'Function';
|
||||
|
||||
/** Argument list node types shared between countCallArguments and call-resolution helpers. */
|
||||
export const CALL_ARGUMENT_LIST_TYPES = new Set(['arguments', 'argument_list', 'value_arguments']);
|
||||
|
||||
/**
|
||||
* Extract parameter count and return type text from an AST method/function node.
|
||||
* Works across languages by looking for common AST patterns.
|
||||
*/
|
||||
export const extractMethodSignature = (node: SyntaxNode | null | undefined): MethodSignature => {
|
||||
let parameterCount: number | undefined = 0;
|
||||
let requiredCount = 0;
|
||||
let returnType: string | undefined;
|
||||
let isVariadic = false;
|
||||
const paramTypes: string[] = [];
|
||||
|
||||
if (!node)
|
||||
return {
|
||||
parameterCount,
|
||||
requiredParameterCount: undefined,
|
||||
parameterTypes: undefined,
|
||||
returnType,
|
||||
};
|
||||
|
||||
const paramListTypes = new Set([
|
||||
'formal_parameters',
|
||||
'parameters',
|
||||
'parameter_list',
|
||||
'function_parameters',
|
||||
'method_parameters',
|
||||
'function_value_parameters',
|
||||
'formal_parameter_list', // Dart
|
||||
]);
|
||||
|
||||
// Node types that indicate variadic/rest parameters
|
||||
const VARIADIC_PARAM_TYPES = new Set([
|
||||
'variadic_parameter_declaration', // Go: ...string
|
||||
'variadic_parameter', // Rust: extern "C" fn(...)
|
||||
'spread_parameter', // Java: Object... args
|
||||
'list_splat_pattern', // Python: *args
|
||||
'dictionary_splat_pattern', // Python: **kwargs
|
||||
]);
|
||||
|
||||
/** AST node types that represent parameters with default values. */
|
||||
const OPTIONAL_PARAM_TYPES = new Set([
|
||||
'optional_parameter', // TypeScript, Ruby: (x?: number), (x: number = 5), def f(x = 5)
|
||||
'default_parameter', // Python: def f(x=5)
|
||||
'typed_default_parameter', // Python: def f(x: int = 5)
|
||||
'optional_parameter_declaration', // C++: void f(int x = 5)
|
||||
]);
|
||||
|
||||
/** Check if a parameter node has a default value (handles Kotlin, C#, Swift, PHP
|
||||
* where defaults are expressed as child nodes rather than distinct node types). */
|
||||
const hasDefaultValue = (paramNode: SyntaxNode): boolean => {
|
||||
if (OPTIONAL_PARAM_TYPES.has(paramNode.type)) return true;
|
||||
// C#, Swift, PHP: check for '=' token or equals_value_clause child
|
||||
for (let i = 0; i < paramNode.childCount; i++) {
|
||||
const c = paramNode.child(i);
|
||||
if (!c) continue;
|
||||
if (c.type === '=' || c.type === 'equals_value_clause') return true;
|
||||
}
|
||||
// Kotlin: default values are siblings of the parameter node, not children.
|
||||
// The AST is: parameter, =, <literal> — all at function_value_parameters level.
|
||||
// Check if the immediately following sibling is '=' (default value separator).
|
||||
const sib = paramNode.nextSibling;
|
||||
if (sib && sib.type === '=') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const findParameterList = (current: SyntaxNode): SyntaxNode | null => {
|
||||
for (const child of current.children) {
|
||||
if (paramListTypes.has(child.type)) return child;
|
||||
}
|
||||
for (const child of current.children) {
|
||||
const nested = findParameterList(child);
|
||||
if (nested) return nested;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parameterList = paramListTypes.has(node.type)
|
||||
? node // node itself IS the parameter list (e.g. C# primary constructors)
|
||||
: (node.childForFieldName?.('parameters') ?? findParameterList(node));
|
||||
|
||||
if (parameterList && paramListTypes.has(parameterList.type)) {
|
||||
for (const param of parameterList.namedChildren) {
|
||||
if (param.type === 'comment') continue;
|
||||
if (
|
||||
param.text === 'self' ||
|
||||
param.text === '&self' ||
|
||||
param.text === '&mut self' ||
|
||||
param.type === 'self_parameter'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Kotlin: default values are siblings of the parameter node inside
|
||||
// function_value_parameters, so they appear as named children (e.g.
|
||||
// string_literal, integer_literal, boolean_literal, call_expression).
|
||||
// Skip any named child that isn't a parameter-like or modifier node.
|
||||
if (
|
||||
param.type.endsWith('_literal') ||
|
||||
param.type === 'call_expression' ||
|
||||
param.type === 'navigation_expression' ||
|
||||
param.type === 'prefix_expression' ||
|
||||
param.type === 'parenthesized_expression'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Check for variadic parameter types
|
||||
if (VARIADIC_PARAM_TYPES.has(param.type)) {
|
||||
isVariadic = true;
|
||||
continue;
|
||||
}
|
||||
// TypeScript/JavaScript: rest parameter — required_parameter containing rest_pattern
|
||||
if (param.type === 'required_parameter' || param.type === 'optional_parameter') {
|
||||
for (const child of param.children) {
|
||||
if (child.type === 'rest_pattern') {
|
||||
isVariadic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isVariadic) continue;
|
||||
}
|
||||
// Kotlin: vararg modifier on a regular parameter
|
||||
if (param.type === 'parameter' || param.type === 'formal_parameter') {
|
||||
const prev = param.previousSibling;
|
||||
if (prev?.type === 'parameter_modifiers' && prev.text.includes('vararg')) {
|
||||
isVariadic = true;
|
||||
}
|
||||
}
|
||||
// Extract parameter type name for overload disambiguation.
|
||||
// Works for Java (formal_parameter), Kotlin (parameter), C# (parameter),
|
||||
// C++ (parameter_declaration). Uses childForFieldName('type') which is the
|
||||
// standard tree-sitter field for typed parameters across these languages.
|
||||
// Kotlin uses positional children instead of 'type' field — fall back to
|
||||
// searching for user_type/nullable_type/predefined_type children.
|
||||
const paramTypeNode = param.childForFieldName('type');
|
||||
if (paramTypeNode) {
|
||||
const typeName = extractSimpleTypeName(paramTypeNode);
|
||||
paramTypes.push(typeName ?? 'unknown');
|
||||
} else {
|
||||
// Kotlin: parameter → [simple_identifier, user_type|nullable_type]
|
||||
let found = false;
|
||||
for (const child of param.namedChildren) {
|
||||
if (
|
||||
child.type === 'user_type' ||
|
||||
child.type === 'nullable_type' ||
|
||||
child.type === 'type_identifier' ||
|
||||
child.type === 'predefined_type'
|
||||
) {
|
||||
const typeName = extractSimpleTypeName(child);
|
||||
paramTypes.push(typeName ?? 'unknown');
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) paramTypes.push('unknown');
|
||||
}
|
||||
if (!hasDefaultValue(param)) requiredCount++;
|
||||
parameterCount++;
|
||||
}
|
||||
// C/C++: bare `...` token in parameter list (not a named child — check all children)
|
||||
if (!isVariadic) {
|
||||
for (const child of parameterList.children) {
|
||||
if (!child.isNamed && child.text === '...') {
|
||||
isVariadic = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Swift fallback: tree-sitter-swift places `parameter` nodes as direct children of
|
||||
// function_declaration without a wrapping parameters/function_parameters list node.
|
||||
// When no parameter list was found, count direct `parameter` children on the node.
|
||||
if (!parameterList && parameterCount === 0) {
|
||||
for (const child of node.namedChildren) {
|
||||
if (child.type === 'parameter') {
|
||||
if (!hasDefaultValue(child)) requiredCount++;
|
||||
parameterCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return type extraction — language-specific field names
|
||||
// Go: 'result' field is either a type_identifier or parameter_list (multi-return)
|
||||
const goResult = node.childForFieldName?.('result');
|
||||
if (goResult) {
|
||||
if (goResult.type === 'parameter_list') {
|
||||
// Multi-return: extract first parameter's type only (e.g. (*User, error) → *User)
|
||||
const firstParam = goResult.firstNamedChild;
|
||||
if (firstParam?.type === 'parameter_declaration') {
|
||||
const typeNode = firstParam.childForFieldName('type');
|
||||
if (typeNode) returnType = typeNode.text;
|
||||
} else if (firstParam) {
|
||||
// Unnamed return types: (string, error) — first child is a bare type node
|
||||
returnType = firstParam.text;
|
||||
}
|
||||
} else {
|
||||
returnType = goResult.text;
|
||||
}
|
||||
}
|
||||
|
||||
// Rust: 'return_type' field — the value IS the type node (e.g. primitive_type, type_identifier).
|
||||
// Skip if the node is a type_annotation (TS/Python), which is handled by the generic loop below.
|
||||
if (!returnType) {
|
||||
const rustReturn = node.childForFieldName?.('return_type');
|
||||
if (rustReturn && rustReturn.type !== 'type_annotation') {
|
||||
returnType = rustReturn.text;
|
||||
}
|
||||
}
|
||||
|
||||
// C/C++: 'type' field on function_definition
|
||||
if (!returnType) {
|
||||
const cppType = node.childForFieldName?.('type');
|
||||
if (cppType && cppType.text !== 'void') {
|
||||
returnType = cppType.text;
|
||||
}
|
||||
}
|
||||
|
||||
// C#: 'returns' field on method_declaration
|
||||
if (!returnType) {
|
||||
const csReturn = node.childForFieldName?.('returns');
|
||||
if (csReturn && csReturn.text !== 'void') {
|
||||
returnType = csReturn.text;
|
||||
}
|
||||
}
|
||||
|
||||
// TS/Rust/Python/C#/Kotlin: type_annotation or return_type child
|
||||
if (!returnType) {
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'type_annotation' || child.type === 'return_type') {
|
||||
const typeNode = child.children.find((c) => c.isNamed);
|
||||
if (typeNode) returnType = typeNode.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kotlin: fun getUser(): User — return type is a bare user_type child of
|
||||
// function_declaration. The Kotlin grammar does NOT wrap it in type_annotation
|
||||
// or return_type; it appears as a direct child after function_value_parameters.
|
||||
// Note: Kotlin uses function_value_parameters (not a field), so we find it by type.
|
||||
if (!returnType) {
|
||||
let paramsEnd = -1;
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
if (child.type === 'function_value_parameters' || child.type === 'value_parameters') {
|
||||
paramsEnd = child.endIndex;
|
||||
}
|
||||
if (paramsEnd >= 0 && child.type === 'user_type' && child.startIndex > paramsEnd) {
|
||||
returnType = child.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isVariadic) parameterCount = undefined;
|
||||
|
||||
// Only include parameterTypes when at least one type was successfully extracted.
|
||||
// Use undefined (not []) to avoid empty array allocations for untyped parameters.
|
||||
const hasTypes = paramTypes.length > 0 && paramTypes.some((t) => t !== 'unknown');
|
||||
// Only set requiredParameterCount when it differs from total — saves memory on the common case.
|
||||
const requiredParameterCount =
|
||||
!isVariadic && requiredCount < (parameterCount ?? 0) ? requiredCount : undefined;
|
||||
return {
|
||||
parameterCount,
|
||||
requiredParameterCount,
|
||||
parameterTypes: hasTypes ? paramTypes : undefined,
|
||||
returnType,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Generic AST traversal helpers (shared by parse-worker + php-helpers)
|
||||
// ============================================================================
|
||||
@@ -865,18 +430,6 @@ export function extractStringContent(node: SyntaxNode | null | undefined): strin
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if a C/C++ function_definition is inside a class or struct body.
|
||||
* Used by the C/C++ labelOverride to skip duplicate function captures
|
||||
* that are already covered by definition.method queries. */
|
||||
export function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean {
|
||||
let ancestor: SyntaxNode | null = functionNode?.parent ?? null;
|
||||
while (ancestor) {
|
||||
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
|
||||
ancestor = ancestor.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Find the first direct named child of a tree-sitter node matching the given type. */
|
||||
export function findChild(node: SyntaxNode, type: string): SyntaxNode | null {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { MethodInfo } from '../method-types.js';
|
||||
|
||||
/**
|
||||
* Compute arity for ID-generation purposes.
|
||||
* Returns `undefined` when any parameter is variadic (arity is indeterminate).
|
||||
*/
|
||||
export function arityForIdFromInfo(info: MethodInfo): number | undefined {
|
||||
return info.parameters.some((p) => p.isVariadic) ? undefined : info.parameters.length;
|
||||
}
|
||||
|
||||
/** Convert MethodInfo from methodExtractor into flat properties for a graph node. */
|
||||
export function buildMethodProps(info: MethodInfo): Record<string, unknown> {
|
||||
const types: string[] = [];
|
||||
let optionalCount = 0;
|
||||
let hasVariadic = false;
|
||||
for (const p of info.parameters) {
|
||||
if (p.type !== null) types.push(p.type);
|
||||
if (p.isOptional) optionalCount++;
|
||||
if (p.isVariadic) hasVariadic = true;
|
||||
}
|
||||
return {
|
||||
parameterCount: hasVariadic ? undefined : info.parameters.length,
|
||||
...(!hasVariadic && optionalCount > 0
|
||||
? { requiredParameterCount: info.parameters.length - optionalCount }
|
||||
: {}),
|
||||
...(types.length > 0 ? { parameterTypes: types } : {}),
|
||||
returnType: info.returnType ?? undefined,
|
||||
visibility: info.visibility,
|
||||
isStatic: info.isStatic,
|
||||
isAbstract: info.isAbstract,
|
||||
isFinal: info.isFinal,
|
||||
...(info.isVirtual ? { isVirtual: info.isVirtual } : {}),
|
||||
...(info.isOverride ? { isOverride: info.isOverride } : {}),
|
||||
...(info.isAsync ? { isAsync: info.isAsync } : {}),
|
||||
...(info.isPartial ? { isPartial: info.isPartial } : {}),
|
||||
...(info.annotations.length > 0 ? { annotations: info.annotations } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Vue SFC (Single File Component) script extractor.
|
||||
*
|
||||
* Extracts the <script> / <script setup> block content from .vue files
|
||||
* so it can be parsed by the TypeScript tree-sitter grammar.
|
||||
*
|
||||
* Pure function — no tree-sitter dependency, safe for worker threads.
|
||||
*/
|
||||
|
||||
export interface VueScriptExtraction {
|
||||
/** Extracted script content (TypeScript/JavaScript) */
|
||||
scriptContent: string;
|
||||
/** 0-based line number in the .vue file where the script content starts */
|
||||
lineOffset: number;
|
||||
/** true if the primary block is <script setup> */
|
||||
isSetup: boolean;
|
||||
}
|
||||
|
||||
interface ScriptBlock {
|
||||
content: string;
|
||||
lineOffset: number;
|
||||
isSetup: boolean;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
const SCRIPT_RE = /<script(\s[^>]*)?>([^]*?)<\/script>/g;
|
||||
const TEMPLATE_COMPONENT_RE = /<([A-Z][A-Za-z0-9]+)/g;
|
||||
// Greedy: matches from the first <template> to the *last* </template>.
|
||||
// This is intentional — nested <template v-slot:...> tags are valid Vue
|
||||
// syntax and we want the entire outermost template body.
|
||||
const TEMPLATE_RE = /<template(\s[^>]*)?>([^]*)<\/template>/;
|
||||
|
||||
function countNewlines(text: string): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function parseScriptBlock(
|
||||
attrs: string | undefined,
|
||||
content: string,
|
||||
precedingText: string,
|
||||
): ScriptBlock {
|
||||
const isSetup = attrs != null && /\bsetup\b/.test(attrs);
|
||||
const langMatch = attrs?.match(/\blang\s*=\s*["']([^"']+)["']/);
|
||||
const lang = langMatch ? langMatch[1] : '';
|
||||
// +1 for the newline after the opening <script...> tag
|
||||
const lineOffset = countNewlines(precedingText) + 1;
|
||||
|
||||
return { content, lineOffset, isSetup, lang };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract script content from a Vue SFC.
|
||||
*
|
||||
* When both <script> and <script setup> are present, returns only the
|
||||
* <script setup> block (the dominant pattern — 94% of Vue files in real
|
||||
* projects use setup). The <script> (non-setup) block typically contains
|
||||
* only `defineOptions` or legacy option merges and is less important for
|
||||
* the knowledge graph.
|
||||
*/
|
||||
export function extractVueScript(vueContent: string): VueScriptExtraction | null {
|
||||
const blocks: ScriptBlock[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset lastIndex for reuse of the global regex
|
||||
SCRIPT_RE.lastIndex = 0;
|
||||
while ((match = SCRIPT_RE.exec(vueContent)) !== null) {
|
||||
const precedingText = vueContent.slice(0, match.index + match[0].indexOf(match[2]));
|
||||
blocks.push(parseScriptBlock(match[1], match[2], precedingText));
|
||||
}
|
||||
|
||||
if (blocks.length === 0) return null;
|
||||
|
||||
// Prefer <script setup> if present
|
||||
const setupBlock = blocks.find((b) => b.isSetup);
|
||||
const primary = setupBlock ?? blocks[0];
|
||||
|
||||
return {
|
||||
scriptContent: primary.content,
|
||||
lineOffset: primary.lineOffset,
|
||||
isSetup: primary.isSetup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue <script setup>: all top-level bindings are implicitly exported.
|
||||
* Returns true if the node (or any ancestor) has the `program` root as its
|
||||
* direct parent — i.e. the node is at the top level of the script block.
|
||||
*
|
||||
* Shared between the worker and sequential parsing paths.
|
||||
*/
|
||||
export const isVueSetupTopLevel = (
|
||||
node: { parent: { type: string; parent: unknown } | null } | null,
|
||||
): boolean => {
|
||||
if (!node) return false;
|
||||
let current: { parent: { type: string; parent: unknown } | null } | null = node;
|
||||
while (current) {
|
||||
if (current.parent?.type === 'program') return true;
|
||||
current = current.parent as typeof current;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract PascalCase component names used in <template>.
|
||||
* Returns deduplicated component names (e.g., ["MyButton", "AppHeader"]).
|
||||
*/
|
||||
export function extractTemplateComponents(vueContent: string): string[] {
|
||||
const templateMatch = TEMPLATE_RE.exec(vueContent);
|
||||
if (!templateMatch) return [];
|
||||
|
||||
const templateContent = templateMatch[2];
|
||||
const components = new Set<string>();
|
||||
let componentMatch: RegExpExecArray | null;
|
||||
|
||||
TEMPLATE_COMPONENT_RE.lastIndex = 0;
|
||||
while ((componentMatch = TEMPLATE_COMPONENT_RE.exec(templateContent)) !== null) {
|
||||
components.add(componentMatch[1]);
|
||||
}
|
||||
|
||||
return [...components];
|
||||
}
|
||||
@@ -41,13 +41,15 @@ try {
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import {
|
||||
FUNCTION_NODE_TYPES,
|
||||
extractFunctionName,
|
||||
getDefinitionNodeFromCaptures,
|
||||
findEnclosingClassId,
|
||||
findEnclosingClassInfo,
|
||||
type EnclosingClassInfo,
|
||||
getLabelFromCaptures,
|
||||
extractMethodSignature,
|
||||
findDescendant,
|
||||
extractStringContent,
|
||||
genericFuncName,
|
||||
inferFunctionLabel,
|
||||
CLASS_CONTAINER_TYPES,
|
||||
type SyntaxNode,
|
||||
} from '../utils/ast-helpers.js';
|
||||
import {
|
||||
@@ -65,11 +67,17 @@ import type { ConstructorBinding } from '../type-env.js';
|
||||
import { detectFrameworkFromAST } from '../framework-detection.js';
|
||||
import { generateId } from '../../../lib/utils.js';
|
||||
import { preprocessImportPath } from '../import-processor.js';
|
||||
import {
|
||||
extractVueScript,
|
||||
extractTemplateComponents,
|
||||
isVueSetupTopLevel,
|
||||
} from '../vue-sfc-extractor.js';
|
||||
import type { NamedBinding } from '../named-bindings/types.js';
|
||||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { FieldInfo, FieldExtractorContext } from '../field-types.js';
|
||||
import type { MethodInfo, MethodExtractorContext } from '../method-types.js';
|
||||
import { CLASS_CONTAINER_TYPES } from '../utils/ast-helpers.js';
|
||||
import { buildMethodProps, arityForIdFromInfo } from '../utils/method-props.js';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
|
||||
// ============================================================================
|
||||
// Types for serializable results
|
||||
@@ -88,14 +96,8 @@ interface ParsedNode {
|
||||
astFrameworkMultiplier?: number;
|
||||
astFrameworkReason?: string;
|
||||
description?: string;
|
||||
parameterCount?: number;
|
||||
requiredParameterCount?: number;
|
||||
returnType?: string;
|
||||
// Field/property metadata (populated by FieldExtractor)
|
||||
declaredType?: string;
|
||||
visibility?: string;
|
||||
isStatic?: boolean;
|
||||
isReadonly?: boolean;
|
||||
// Method/field metadata — extensible via buildMethodProps spread
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,6 +285,7 @@ const languageMap: Record<string, TreeSitterLanguage> = {
|
||||
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
[SupportedLanguages.Ruby]: Ruby,
|
||||
[SupportedLanguages.Vue]: TypeScript.typescript,
|
||||
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
};
|
||||
@@ -318,7 +321,7 @@ const setLanguage = (language: SupportedLanguages, filePath: string): void => {
|
||||
// a stored null (enclosing class/function not found = top-level).
|
||||
// ============================================================================
|
||||
|
||||
const classIdCache = new Map<SyntaxNode, string | null>();
|
||||
const classIdCache = new Map<SyntaxNode, EnclosingClassInfo | null>();
|
||||
const functionIdCache = new Map<SyntaxNode, string | null>();
|
||||
const exportCache = new Map<SyntaxNode, boolean>();
|
||||
|
||||
@@ -345,6 +348,12 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (CLASS_CONTAINER_TYPES.has(current.type)) {
|
||||
// Ruby singleton_class (class << self) has no name field — walk up to
|
||||
// the enclosing class/module so the caller gets a node with a findable name.
|
||||
if (current.type === 'singleton_class') {
|
||||
current = current.parent;
|
||||
continue;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
current = current.parent;
|
||||
@@ -352,6 +361,75 @@ function findEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* For C++ out-of-class method definitions (e.g. `void Foo::bar() {}`), extract the
|
||||
* class name from the qualified_identifier scope and find the class declaration in the
|
||||
* file's AST. Returns the class SyntaxNode or null if not found.
|
||||
*
|
||||
* Handles pointer/reference return types where function_declarator is nested inside
|
||||
* pointer_declarator or reference_declarator.
|
||||
*/
|
||||
function findClassNodeByQualifiedName(node: SyntaxNode): SyntaxNode | null {
|
||||
const declarator = node.childForFieldName('declarator');
|
||||
if (!declarator) return null;
|
||||
|
||||
// Find the function_declarator, recursively unwrapping pointer_declarator /
|
||||
// reference_declarator chains (e.g. int** Foo::bar() has
|
||||
// pointer_declarator → pointer_declarator → function_declarator).
|
||||
let funcDecl: SyntaxNode | null = null;
|
||||
if (declarator.type === 'function_declarator') {
|
||||
funcDecl = declarator;
|
||||
} else {
|
||||
let current: SyntaxNode | null = declarator;
|
||||
while (current && !funcDecl) {
|
||||
for (let i = 0; i < current.namedChildCount; i++) {
|
||||
const child = current.namedChild(i);
|
||||
if (child?.type === 'function_declarator') {
|
||||
funcDecl = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!funcDecl) {
|
||||
const next = current.namedChildren.find(
|
||||
(c) => c.type === 'pointer_declarator' || c.type === 'reference_declarator',
|
||||
);
|
||||
current = next ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!funcDecl) return null;
|
||||
|
||||
// Check if the inner declarator is a qualified_identifier (Foo::bar)
|
||||
const innerDecl = funcDecl.childForFieldName('declarator');
|
||||
if (!innerDecl || innerDecl.type !== 'qualified_identifier') return null;
|
||||
|
||||
const scope = innerDecl.childForFieldName('scope');
|
||||
if (!scope) return null;
|
||||
const className = scope.text;
|
||||
|
||||
// Search the file for a matching class/struct specifier, including inside
|
||||
// namespace_definition blocks (the majority of production C++ uses namespaces).
|
||||
const root = node.tree.rootNode;
|
||||
const classTypes = new Set(['class_specifier', 'struct_specifier']);
|
||||
const searchIn = (parent: SyntaxNode): SyntaxNode | null => {
|
||||
for (let i = 0; i < parent.namedChildCount; i++) {
|
||||
const child = parent.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (classTypes.has(child.type)) {
|
||||
const nameNode = child.childForFieldName('name');
|
||||
if (nameNode?.text === className) return child;
|
||||
}
|
||||
// Recurse into namespace blocks
|
||||
if (child.type === 'namespace_definition') {
|
||||
const found = searchIn(child);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return searchIn(root);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal no-op SymbolTable stub for FieldExtractorContext in the worker.
|
||||
* Field extraction only uses symbolTable.lookupExactAll for optional type resolution —
|
||||
@@ -429,8 +507,6 @@ function getMethodInfo(
|
||||
// Enclosing function detection (for call extraction) — cached
|
||||
// ============================================================================
|
||||
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
|
||||
/** Walk up AST to find enclosing function, return its generateId or null for top-level.
|
||||
* Applies provider.labelOverride so the label matches the definition phase (single source of truth). */
|
||||
const findEnclosingFunctionId = (
|
||||
@@ -444,7 +520,9 @@ const findEnclosingFunctionId = (
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (FUNCTION_NODE_TYPES.has(current.type)) {
|
||||
const { funcName, label } = extractFunctionName(current);
|
||||
const efnResult = provider.methodExtractor?.extractFunctionName?.(current);
|
||||
const funcName = efnResult?.funcName ?? genericFuncName(current);
|
||||
const label = efnResult?.label ?? inferFunctionLabel(current.type);
|
||||
if (funcName) {
|
||||
// Apply labelOverride so label matches definition phase (e.g., Kotlin Function→Method).
|
||||
// null means "skip as definition" — keep original label for scope identification.
|
||||
@@ -453,7 +531,31 @@ const findEnclosingFunctionId = (
|
||||
const override = provider.labelOverride(current, label);
|
||||
if (override !== null) finalLabel = override;
|
||||
}
|
||||
const result = generateId(finalLabel, `${filePath}:${funcName}`);
|
||||
// Qualify with enclosing class to match definition-phase node IDs
|
||||
const classInfo = cachedFindEnclosingClassInfo(current, filePath);
|
||||
const qualifiedName = classInfo ? `${classInfo.className}.${funcName}` : funcName;
|
||||
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
|
||||
// Use the same MethodExtractor (getMethodInfo) as the definition phase.
|
||||
let arity: number | undefined;
|
||||
if (finalLabel === 'Method' || finalLabel === 'Constructor') {
|
||||
const classNode =
|
||||
findEnclosingClassNode(current) ?? findClassNodeByQualifiedName(current);
|
||||
if (classNode) {
|
||||
const methodMap = getMethodInfo(classNode, provider, {
|
||||
filePath,
|
||||
language: getLanguageFromFilename(filePath),
|
||||
});
|
||||
const defLine = current.startPosition.row + 1;
|
||||
const info = methodMap?.get(`${funcName}:${defLine}`);
|
||||
if (info) {
|
||||
arity = info.parameters.some((p) => p.isVariadic)
|
||||
? undefined
|
||||
: info.parameters.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
const arityTag = arity !== undefined ? `#${arity}` : '';
|
||||
const result = generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag}`);
|
||||
functionIdCache.set(node, result);
|
||||
return result;
|
||||
}
|
||||
@@ -469,7 +571,36 @@ const findEnclosingFunctionId = (
|
||||
const override = provider.labelOverride(current.previousSibling, finalLabel);
|
||||
if (override !== null) finalLabel = override;
|
||||
}
|
||||
const result = generateId(finalLabel, `${filePath}:${customResult.funcName}`);
|
||||
// Qualify custom result with enclosing class
|
||||
const classInfo = cachedFindEnclosingClassInfo(
|
||||
current.previousSibling ?? current,
|
||||
filePath,
|
||||
);
|
||||
const qualifiedName = classInfo
|
||||
? `${classInfo.className}.${customResult.funcName}`
|
||||
: customResult.funcName;
|
||||
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
|
||||
const sigNode = current.previousSibling ?? current;
|
||||
let arity2: number | undefined;
|
||||
if (finalLabel === 'Method' || finalLabel === 'Constructor') {
|
||||
const classNode2 =
|
||||
findEnclosingClassNode(sigNode) ?? findClassNodeByQualifiedName(sigNode);
|
||||
if (classNode2) {
|
||||
const methodMap2 = getMethodInfo(classNode2, provider, {
|
||||
filePath,
|
||||
language: getLanguageFromFilename(filePath),
|
||||
});
|
||||
const defLine2 = sigNode.startPosition.row + 1;
|
||||
const info2 = methodMap2?.get(`${customResult.funcName}:${defLine2}`);
|
||||
if (info2) {
|
||||
arity2 = info2.parameters.some((p) => p.isVariadic)
|
||||
? undefined
|
||||
: info2.parameters.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
const arityTag2 = arity2 !== undefined ? `#${arity2}` : '';
|
||||
const result = generateId(finalLabel, `${filePath}:${qualifiedName}${arityTag2}`);
|
||||
functionIdCache.set(node, result);
|
||||
return result;
|
||||
}
|
||||
@@ -481,12 +612,15 @@ const findEnclosingFunctionId = (
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Cached wrapper for findEnclosingClassId — avoids repeated parent walks. */
|
||||
const cachedFindEnclosingClassId = (node: SyntaxNode, filePath: string): string | null => {
|
||||
/** Cached wrapper for findEnclosingClassInfo — avoids repeated parent walks. */
|
||||
const cachedFindEnclosingClassInfo = (
|
||||
node: SyntaxNode,
|
||||
filePath: string,
|
||||
): EnclosingClassInfo | null => {
|
||||
const cached = classIdCache.get(node);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const result = findEnclosingClassId(node, filePath);
|
||||
const result = findEnclosingClassInfo(node, filePath);
|
||||
classIdCache.set(node, result);
|
||||
return result;
|
||||
};
|
||||
@@ -1143,12 +1277,24 @@ const processFileGroup = (
|
||||
// Skip files larger than the max tree-sitter buffer (32 MB)
|
||||
if (file.content.length > TREE_SITTER_MAX_BUFFER) continue;
|
||||
|
||||
// Vue SFC preprocessing: extract <script> block content
|
||||
let parseContent = file.content;
|
||||
let lineOffset = 0;
|
||||
let isVueSetup = false;
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const extracted = extractVueScript(file.content);
|
||||
if (!extracted) continue; // skip .vue files with no script block
|
||||
parseContent = extracted.scriptContent;
|
||||
lineOffset = extracted.lineOffset;
|
||||
isVueSetup = extracted.isSetup;
|
||||
}
|
||||
|
||||
clearCaches(); // Reset memoization before each new file
|
||||
|
||||
let tree;
|
||||
try {
|
||||
tree = parser.parse(file.content, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(file.content.length),
|
||||
tree = parser.parse(parseContent, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(parseContent.length),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
@@ -1204,6 +1350,7 @@ const processFileGroup = (
|
||||
const typeEnv = buildTypeEnv(tree, language, {
|
||||
parentMap,
|
||||
enclosingFunctionFinder: provider?.enclosingFunctionFinder,
|
||||
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
|
||||
});
|
||||
const callRouter = provider.callRouter;
|
||||
|
||||
@@ -1301,7 +1448,7 @@ const processFileGroup = (
|
||||
routePath,
|
||||
httpMethod,
|
||||
decoratorName,
|
||||
lineNumber: decoratorNode.startPosition.row,
|
||||
lineNumber: decoratorNode.startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
// MCP/RPC tool detection: @mcp.tool(), @app.tool(), @server.tool()
|
||||
@@ -1323,7 +1470,7 @@ const processFileGroup = (
|
||||
result.fetchCalls.push({
|
||||
filePath: file.path,
|
||||
fetchURL: urlNode.text,
|
||||
lineNumber: captureMap['route.fetch'].startPosition.row,
|
||||
lineNumber: captureMap['route.fetch'].startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -1339,7 +1486,7 @@ const processFileGroup = (
|
||||
result.fetchCalls.push({
|
||||
filePath: file.path,
|
||||
fetchURL: url,
|
||||
lineNumber: captureMap['http_client'].startPosition.row,
|
||||
lineNumber: captureMap['http_client'].startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -1363,7 +1510,7 @@ const processFileGroup = (
|
||||
routePath,
|
||||
httpMethod,
|
||||
decoratorName: `express.${method}`,
|
||||
lineNumber: captureMap['express_route'].startPosition.row,
|
||||
lineNumber: captureMap['express_route'].startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -1438,10 +1585,8 @@ const processFileGroup = (
|
||||
}
|
||||
|
||||
if (routed.kind === 'properties') {
|
||||
const propEnclosingClassId = cachedFindEnclosingClassId(
|
||||
captureMap['call'],
|
||||
file.path,
|
||||
);
|
||||
const propEnclosingInfo = cachedFindEnclosingClassInfo(captureMap['call'], file.path);
|
||||
const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
|
||||
// Enrich routed properties with FieldExtractor metadata
|
||||
let routedFieldMap: Map<string, FieldInfo> | undefined;
|
||||
if (provider.fieldExtractor && typeEnv) {
|
||||
@@ -1457,7 +1602,10 @@ const processFileGroup = (
|
||||
}
|
||||
for (const item of routed.items) {
|
||||
const routedFieldInfo = routedFieldMap?.get(item.propName);
|
||||
const nodeId = generateId('Property', `${file.path}:${item.propName}`);
|
||||
const propQualifiedName = propEnclosingInfo
|
||||
? `${propEnclosingInfo.className}.${item.propName}`
|
||||
: item.propName;
|
||||
const nodeId = generateId('Property', `${file.path}:${propQualifiedName}`);
|
||||
result.nodes.push({
|
||||
id: nodeId,
|
||||
label: 'Property',
|
||||
@@ -1646,11 +1794,77 @@ const processFileGroup = (
|
||||
const nodeName = nameNode ? nameNode.text : 'init';
|
||||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const startLine = definitionNode
|
||||
? definitionNode.startPosition.row
|
||||
? definitionNode.startPosition.row + lineOffset
|
||||
: nameNode
|
||||
? nameNode.startPosition.row
|
||||
: 0;
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
|
||||
? nameNode.startPosition.row + lineOffset
|
||||
: lineOffset;
|
||||
|
||||
// Compute enclosing class BEFORE node ID — needed to qualify method IDs
|
||||
const needsOwner =
|
||||
nodeLabel === 'Method' ||
|
||||
nodeLabel === 'Constructor' ||
|
||||
nodeLabel === 'Property' ||
|
||||
nodeLabel === 'Function';
|
||||
const enclosingClassInfo = needsOwner
|
||||
? cachedFindEnclosingClassInfo(nameNode || definitionNode, file.path)
|
||||
: null;
|
||||
const enclosingClassId = enclosingClassInfo?.classId ?? null;
|
||||
|
||||
// Qualify method/property IDs with enclosing class name to avoid collisions
|
||||
const qualifiedName = enclosingClassInfo
|
||||
? `${enclosingClassInfo.className}.${nodeName}`
|
||||
: nodeName;
|
||||
|
||||
// Extract method metadata BEFORE generating node ID — parameterCount is needed
|
||||
// to disambiguate overloaded methods via #<arity> suffix in the ID.
|
||||
let declaredType: string | undefined;
|
||||
let methodProps: Record<string, unknown> = {};
|
||||
let arityForId: number | undefined; // raw param count for ID, even for variadic
|
||||
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
|
||||
// Use MethodExtractor for method metadata — provides parameterCount, parameterTypes,
|
||||
// returnType, isAbstract/isFinal/annotations, visibility, and more.
|
||||
let enrichedByMethodExtractor = false;
|
||||
if (provider.methodExtractor && definitionNode) {
|
||||
const classNode =
|
||||
findEnclosingClassNode(definitionNode) ?? findClassNodeByQualifiedName(definitionNode);
|
||||
if (classNode) {
|
||||
const methodMap = getMethodInfo(classNode, provider, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
const defLine = definitionNode.startPosition.row + 1;
|
||||
const info = methodMap?.get(`${nodeName}:${defLine}`);
|
||||
if (info) {
|
||||
enrichedByMethodExtractor = true;
|
||||
arityForId = arityForIdFromInfo(info);
|
||||
methodProps = buildMethodProps(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For top-level methods (e.g. Go method_declaration), try extractFromNode
|
||||
if (
|
||||
!enrichedByMethodExtractor &&
|
||||
provider.methodExtractor?.extractFromNode &&
|
||||
definitionNode
|
||||
) {
|
||||
const info = provider.methodExtractor.extractFromNode(definitionNode, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
if (info) {
|
||||
enrichedByMethodExtractor = true;
|
||||
arityForId = arityForIdFromInfo(info);
|
||||
methodProps = buildMethodProps(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append #<paramCount> to Method/Constructor IDs to disambiguate overloads.
|
||||
// Functions are not suffixed — they don't overload by name in the same scope.
|
||||
const needsAritySuffix = nodeLabel === 'Method' || nodeLabel === 'Constructor';
|
||||
const arityTag = needsAritySuffix && arityForId !== undefined ? `#${arityForId}` : '';
|
||||
const nodeId = generateId(nodeLabel, `${file.path}:${qualifiedName}${arityTag}`);
|
||||
|
||||
const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap);
|
||||
|
||||
@@ -1684,7 +1898,7 @@ const processFileGroup = (
|
||||
filePath: file.path,
|
||||
toolName: nodeName,
|
||||
description: dec.arg || '',
|
||||
lineNumber: definitionNode.startPosition.row,
|
||||
lineNumber: definitionNode.startPosition.row + lineOffset,
|
||||
});
|
||||
}
|
||||
fileDecorators.delete(checkLine);
|
||||
@@ -1692,82 +1906,8 @@ const processFileGroup = (
|
||||
}
|
||||
}
|
||||
|
||||
let parameterCount: number | undefined;
|
||||
let requiredParameterCount: number | undefined;
|
||||
let parameterTypes: string[] | undefined;
|
||||
let returnType: string | undefined;
|
||||
let declaredType: string | undefined;
|
||||
let visibility: string | undefined;
|
||||
let isStatic: boolean | undefined;
|
||||
let isReadonly: boolean | undefined;
|
||||
let isAbstract: boolean | undefined;
|
||||
let isFinal: boolean | undefined;
|
||||
let isVirtual: boolean | undefined;
|
||||
let isOverride: boolean | undefined;
|
||||
let isAsync: boolean | undefined;
|
||||
let isPartial: boolean | undefined;
|
||||
let annotations: string[] | undefined;
|
||||
if (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') {
|
||||
// Try MethodExtractor first — it provides everything extractMethodSignature does, plus
|
||||
// isAbstract/isFinal/annotations. Only fall back to extractMethodSignature when no
|
||||
// MethodExtractor is available or the method isn't inside a class body.
|
||||
let enrichedByMethodExtractor = false;
|
||||
if (provider.methodExtractor && definitionNode) {
|
||||
const classNode = findEnclosingClassNode(definitionNode);
|
||||
if (classNode) {
|
||||
const methodMap = getMethodInfo(classNode, provider, {
|
||||
filePath: file.path,
|
||||
language,
|
||||
});
|
||||
const defLine = definitionNode.startPosition.row + 1;
|
||||
const info = methodMap?.get(`${nodeName}:${defLine}`);
|
||||
if (info) {
|
||||
enrichedByMethodExtractor = true;
|
||||
parameterCount = info.parameters.length;
|
||||
const types: string[] = [];
|
||||
let optionalCount = 0;
|
||||
for (const p of info.parameters) {
|
||||
if (p.type !== null) types.push(p.type);
|
||||
if (p.isOptional) optionalCount++;
|
||||
}
|
||||
parameterTypes = types.length > 0 ? types : undefined;
|
||||
requiredParameterCount =
|
||||
optionalCount > 0 ? parameterCount - optionalCount : undefined;
|
||||
returnType = info.returnType ?? undefined;
|
||||
visibility = info.visibility;
|
||||
isStatic = info.isStatic;
|
||||
isAbstract = info.isAbstract;
|
||||
isFinal = info.isFinal;
|
||||
if (info.isVirtual) isVirtual = info.isVirtual;
|
||||
if (info.isOverride) isOverride = info.isOverride;
|
||||
if (info.isAsync) isAsync = info.isAsync;
|
||||
if (info.isPartial) isPartial = info.isPartial;
|
||||
if (info.annotations.length > 0) annotations = info.annotations;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!enrichedByMethodExtractor) {
|
||||
const sig = extractMethodSignature(definitionNode);
|
||||
parameterCount = sig.parameterCount;
|
||||
requiredParameterCount = sig.requiredParameterCount;
|
||||
parameterTypes = sig.parameterTypes;
|
||||
returnType = sig.returnType;
|
||||
}
|
||||
|
||||
// Language-specific return type fallback (e.g. Ruby YARD @return [Type])
|
||||
// Also upgrades uninformative AST types like PHP `array` with PHPDoc `@return User[]`
|
||||
if (
|
||||
(!returnType || returnType === 'array' || returnType === 'iterable') &&
|
||||
definitionNode
|
||||
) {
|
||||
const tc = provider.typeConfig;
|
||||
if (tc?.extractReturnType) {
|
||||
const docReturn = tc.extractReturnType(definitionNode);
|
||||
if (docReturn) returnType = docReturn;
|
||||
}
|
||||
}
|
||||
} else if (nodeLabel === 'Property' && definitionNode) {
|
||||
// Property metadata extraction (not needed before nodeId — Properties don't overload)
|
||||
if (nodeLabel === 'Property' && definitionNode) {
|
||||
// FieldExtractor is the single source of truth when available
|
||||
if (provider.fieldExtractor && typeEnv) {
|
||||
const classNode = findEnclosingClassNode(definitionNode);
|
||||
@@ -1781,9 +1921,9 @@ const processFileGroup = (
|
||||
const info = fieldMap?.get(nodeName);
|
||||
if (info) {
|
||||
declaredType = info.type ?? undefined;
|
||||
visibility = info.visibility;
|
||||
isStatic = info.isStatic;
|
||||
isReadonly = info.isReadonly;
|
||||
methodProps.visibility = info.visibility;
|
||||
methodProps.isStatic = info.isStatic;
|
||||
methodProps.isReadonly = info.isReadonly;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1795,14 +1935,13 @@ const processFileGroup = (
|
||||
properties: {
|
||||
name: nodeName,
|
||||
filePath: file.path,
|
||||
startLine: definitionNode ? definitionNode.startPosition.row : startLine,
|
||||
endLine: definitionNode ? definitionNode.endPosition.row : startLine,
|
||||
startLine: definitionNode ? definitionNode.startPosition.row + lineOffset : startLine,
|
||||
endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine,
|
||||
language: language,
|
||||
isExported: cachedExportCheck(
|
||||
provider.exportChecker,
|
||||
nameNode || definitionNode,
|
||||
nodeName,
|
||||
),
|
||||
isExported:
|
||||
language === SupportedLanguages.Vue && isVueSetup
|
||||
? isVueSetupTopLevel(nameNode || definitionNode)
|
||||
: cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName),
|
||||
...(frameworkHint
|
||||
? {
|
||||
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
|
||||
@@ -1810,56 +1949,42 @@ const processFileGroup = (
|
||||
}
|
||||
: {}),
|
||||
...(description !== undefined ? { description } : {}),
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
|
||||
...(parameterTypes !== undefined ? { parameterTypes } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
...methodProps,
|
||||
...(declaredType !== undefined ? { declaredType } : {}),
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(isStatic !== undefined ? { isStatic } : {}),
|
||||
...(isReadonly !== undefined ? { isReadonly } : {}),
|
||||
...(isAbstract !== undefined ? { isAbstract } : {}),
|
||||
...(isFinal !== undefined ? { isFinal } : {}),
|
||||
...(isVirtual !== undefined ? { isVirtual } : {}),
|
||||
...(isOverride !== undefined ? { isOverride } : {}),
|
||||
...(isAsync !== undefined ? { isAsync } : {}),
|
||||
...(isPartial !== undefined ? { isPartial } : {}),
|
||||
...(annotations !== undefined ? { annotations } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Compute enclosing class for Method/Constructor/Property/Function — used for both ownerId and HAS_METHOD
|
||||
// Function is included because Kotlin/Rust/Python capture class methods as Function nodes
|
||||
const needsOwner =
|
||||
nodeLabel === 'Method' ||
|
||||
nodeLabel === 'Constructor' ||
|
||||
nodeLabel === 'Property' ||
|
||||
nodeLabel === 'Function';
|
||||
const enclosingClassId = needsOwner
|
||||
? cachedFindEnclosingClassId(nameNode || definitionNode, file.path)
|
||||
: null;
|
||||
// enclosingClassId already computed above (before nodeId generation)
|
||||
|
||||
result.symbols.push({
|
||||
filePath: file.path,
|
||||
name: nodeName,
|
||||
nodeId,
|
||||
type: nodeLabel,
|
||||
...(parameterCount !== undefined ? { parameterCount } : {}),
|
||||
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
|
||||
...(parameterTypes !== undefined ? { parameterTypes } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
parameterCount: methodProps.parameterCount as number | undefined,
|
||||
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
|
||||
parameterTypes: methodProps.parameterTypes as string[] | undefined,
|
||||
returnType: methodProps.returnType as string | undefined,
|
||||
...(declaredType !== undefined ? { declaredType } : {}),
|
||||
...(enclosingClassId ? { ownerId: enclosingClassId } : {}),
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(isStatic !== undefined ? { isStatic } : {}),
|
||||
...(isReadonly !== undefined ? { isReadonly } : {}),
|
||||
...(isAbstract !== undefined ? { isAbstract } : {}),
|
||||
...(isFinal !== undefined ? { isFinal } : {}),
|
||||
...(isVirtual !== undefined ? { isVirtual } : {}),
|
||||
...(isOverride !== undefined ? { isOverride } : {}),
|
||||
...(isAsync !== undefined ? { isAsync } : {}),
|
||||
...(isPartial !== undefined ? { isPartial } : {}),
|
||||
...(annotations !== undefined ? { annotations } : {}),
|
||||
visibility: methodProps.visibility as string | undefined,
|
||||
isStatic: methodProps.isStatic as boolean | undefined,
|
||||
isReadonly: methodProps.isReadonly as boolean | undefined,
|
||||
isAbstract: methodProps.isAbstract as boolean | undefined,
|
||||
isFinal: methodProps.isFinal as boolean | undefined,
|
||||
...(methodProps.isVirtual !== undefined
|
||||
? { isVirtual: methodProps.isVirtual as boolean }
|
||||
: {}),
|
||||
...(methodProps.isOverride !== undefined
|
||||
? { isOverride: methodProps.isOverride as boolean }
|
||||
: {}),
|
||||
...(methodProps.isAsync !== undefined ? { isAsync: methodProps.isAsync as boolean } : {}),
|
||||
...(methodProps.isPartial !== undefined
|
||||
? { isPartial: methodProps.isPartial as boolean }
|
||||
: {}),
|
||||
...(methodProps.annotations !== undefined
|
||||
? { annotations: methodProps.annotations as string[] }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const fileId = generateId('File', file.path);
|
||||
@@ -1894,7 +2019,20 @@ const processFileGroup = (
|
||||
}
|
||||
|
||||
// Extract ORM queries (Prisma, Supabase)
|
||||
extractORMQueries(file.path, file.content, result.ormQueries);
|
||||
extractORMQueries(file.path, parseContent, result.ormQueries);
|
||||
|
||||
// Vue: emit CALLS edges for components used in <template>
|
||||
if (language === SupportedLanguages.Vue) {
|
||||
const templateComponents = extractTemplateComponents(file.content);
|
||||
for (const componentName of templateComponents) {
|
||||
result.calls.push({
|
||||
filePath: file.path,
|
||||
calledName: componentName,
|
||||
sourceId: generateId('File', file.path),
|
||||
callForm: 'free',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
/**
|
||||
* LadybugDB connection pool (core). Used by MCP, sync, search, wiki, etc.
|
||||
*
|
||||
* LadybugDB Adapter (Connection Pool)
|
||||
*
|
||||
* Manages a pool of LadybugDB databases keyed by repoId, each with
|
||||
* multiple Connection objects for safe concurrent query execution.
|
||||
*
|
||||
* LadybugDB Connections are NOT thread-safe — a single Connection
|
||||
* segfaults if concurrent .query() calls hit it simultaneously.
|
||||
* This adapter provides a checkout/return connection pool so each
|
||||
* concurrent query gets its own Connection from the same Database.
|
||||
*
|
||||
* @see https://docs.ladybugdb.com/concurrency — multiple Connections
|
||||
* from the same Database is the officially supported concurrency pattern.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import lbug from '@ladybugdb/core';
|
||||
|
||||
/** Per-repo pool: one Database, many Connections */
|
||||
interface PoolEntry {
|
||||
db: lbug.Database;
|
||||
/** Available connections ready for checkout */
|
||||
available: lbug.Connection[];
|
||||
/** Number of connections currently checked out */
|
||||
checkedOut: number;
|
||||
/** Queued waiters for when all connections are busy */
|
||||
waiters: Array<(conn: lbug.Connection) => void>;
|
||||
lastUsed: number;
|
||||
dbPath: string;
|
||||
/** Set to true when the pool entry is closed — checkin will close orphaned connections */
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
const pool = new Map<string, PoolEntry>();
|
||||
|
||||
/**
|
||||
* Shared Database cache keyed by resolved dbPath.
|
||||
* Multiple repoIds pointing to the same path share one native Database
|
||||
* object to avoid exhausting the buffer manager's mmap budget.
|
||||
*/
|
||||
interface SharedDB {
|
||||
db: lbug.Database;
|
||||
refCount: number;
|
||||
ftsLoaded: boolean;
|
||||
/** When true, closeOne skips db.close() — the Database is owned externally. */
|
||||
external?: boolean;
|
||||
}
|
||||
const dbCache = new Map<string, SharedDB>();
|
||||
|
||||
/** Max repos in the pool (LRU eviction) */
|
||||
const MAX_POOL_SIZE = 5;
|
||||
/** Idle timeout before closing a repo's connections */
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/** Max connections per repo (caps concurrent queries per repo) */
|
||||
const MAX_CONNS_PER_REPO = 8;
|
||||
|
||||
let idleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Saved real stdout/stderr write — used to silence native module output without race conditions */
|
||||
export const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
export const realStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
let stdoutSilenceCount = 0;
|
||||
/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
|
||||
let preWarmActive = false;
|
||||
|
||||
/**
|
||||
* Start the idle cleanup timer (runs every 60s)
|
||||
*/
|
||||
function ensureIdleTimer(): void {
|
||||
if (idleTimer) return;
|
||||
idleTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [repoId, entry] of pool) {
|
||||
if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
|
||||
closeOne(repoId);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
|
||||
(idleTimer as NodeJS.Timeout).unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch a repo to reset its idle timeout.
|
||||
* Call this during long-running operations to prevent the connection from being closed.
|
||||
*/
|
||||
export const touchRepo = (repoId: string): void => {
|
||||
const entry = pool.get(repoId);
|
||||
if (entry) {
|
||||
entry.lastUsed = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Evict the least-recently-used repo if pool is at capacity
|
||||
*/
|
||||
function evictLRU(): void {
|
||||
if (pool.size < MAX_POOL_SIZE) return;
|
||||
|
||||
let oldestId: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [id, entry] of pool) {
|
||||
if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) {
|
||||
oldestTime = entry.lastUsed;
|
||||
oldestId = id;
|
||||
}
|
||||
}
|
||||
if (oldestId) {
|
||||
closeOne(oldestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a repo from the pool, close its connections, and release its
|
||||
* shared Database ref. Only closes the Database when no other repoIds
|
||||
* reference it (refCount === 0).
|
||||
*/
|
||||
function closeOne(repoId: string): void {
|
||||
const entry = pool.get(repoId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.closed = true;
|
||||
|
||||
// Close available connections — fire-and-forget with .catch() to prevent
|
||||
// unhandled rejections. Native close() returns Promise<void> but can crash
|
||||
// the N-API destructor on macOS/Windows; deferring to process exit lets
|
||||
// dangerouslyIgnoreUnhandledErrors absorb the crash.
|
||||
for (const conn of entry.available) {
|
||||
conn.close().catch(() => {});
|
||||
}
|
||||
entry.available.length = 0;
|
||||
|
||||
// Checked-out connections can't be closed here — they're in-flight.
|
||||
// The checkin() function detects entry.closed and closes them on return.
|
||||
|
||||
// Only close the Database when no other repoIds reference it.
|
||||
// External databases (injected via initLbugWithDb) are never closed here —
|
||||
// the core adapter owns them and handles their lifecycle.
|
||||
const shared = dbCache.get(entry.dbPath);
|
||||
if (shared) {
|
||||
shared.refCount--;
|
||||
if (shared.refCount === 0) {
|
||||
if (shared.external) {
|
||||
// External databases are owned by the core adapter — don't close
|
||||
// or remove from cache. Keep the entry so future initLbug() calls
|
||||
// for the same dbPath reuse it instead of hitting a file lock.
|
||||
shared.refCount = 0;
|
||||
} else {
|
||||
shared.db.close().catch(() => {});
|
||||
dbCache.delete(entry.dbPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pool.delete(repoId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Connection from a repo's Database.
|
||||
* Silences stdout to prevent native module output from corrupting MCP stdio.
|
||||
*/
|
||||
let activeQueryCount = 0;
|
||||
|
||||
/**
|
||||
* Silence stdout by replacing process.stdout.write with a no-op.
|
||||
* Uses a reference counter so nested silence/restore pairs are safe.
|
||||
* Exported so other modules (e.g. embedder) use the same mechanism instead
|
||||
* of independently patching stdout, which causes restore-order conflicts.
|
||||
*/
|
||||
export function silenceStdout(): void {
|
||||
if (stdoutSilenceCount++ === 0) {
|
||||
process.stdout.write = (() => true) as any;
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreStdout(): void {
|
||||
if (--stdoutSilenceCount <= 0) {
|
||||
stdoutSilenceCount = 0;
|
||||
process.stdout.write = realStdoutWrite;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
|
||||
// inside createConnection before restoreStdout runs).
|
||||
// Exempts active queries and pre-warm — these legitimately hold silence for
|
||||
// longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s).
|
||||
setInterval(() => {
|
||||
if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) {
|
||||
stdoutSilenceCount = 0;
|
||||
process.stdout.write = realStdoutWrite;
|
||||
}
|
||||
}, 1000).unref();
|
||||
|
||||
function createConnection(db: lbug.Database): lbug.Connection {
|
||||
silenceStdout();
|
||||
try {
|
||||
return new lbug.Connection(db);
|
||||
} finally {
|
||||
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;
|
||||
|
||||
/** Deduplicates concurrent initLbug calls for the same repoId */
|
||||
const initPromises = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Initialize (or reuse) a Database + connection pool for a specific repo.
|
||||
* Retries on lock errors (e.g., when `gitnexus analyze` is running).
|
||||
*
|
||||
* Concurrent calls for the same repoId are deduplicated — the second caller
|
||||
* awaits the first's in-progress init rather than starting a redundant one.
|
||||
*/
|
||||
export const initLbug = async (repoId: string, dbPath: string): Promise<void> => {
|
||||
const existing = pool.get(repoId);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent init calls for the same repoId —
|
||||
// prevents double-init race when multiple parallel tool calls
|
||||
// trigger initialization for the same repo simultaneously.
|
||||
const pending = initPromises.get(repoId);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = doInitLbug(repoId, dbPath);
|
||||
initPromises.set(repoId, promise);
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
initPromises.delete(repoId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal init — creates DB, pre-warms connections, loads FTS, then registers pool.
|
||||
* Pool entry is registered LAST so concurrent executeQuery calls see either
|
||||
* "not initialized" (and throw) or a fully ready pool — never a half-built one.
|
||||
*/
|
||||
async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
||||
// Check if database exists
|
||||
try {
|
||||
await fs.stat(dbPath);
|
||||
} catch {
|
||||
throw new Error(`LadybugDB not found at ${dbPath}. Run: gitnexus analyze`);
|
||||
}
|
||||
|
||||
evictLRU();
|
||||
|
||||
// Reuse an existing native Database if another repoId already opened this path.
|
||||
// This prevents buffer manager exhaustion from multiple mmap regions on the same file.
|
||||
let shared = dbCache.get(dbPath);
|
||||
if (!shared) {
|
||||
// Open in read-only mode — MCP server never writes to the database.
|
||||
// This allows multiple MCP server instances to read concurrently, and
|
||||
// avoids lock conflicts when `gitnexus analyze` is writing.
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
silenceStdout();
|
||||
try {
|
||||
const db = new lbug.Database(
|
||||
dbPath,
|
||||
0, // bufferManagerSize (default)
|
||||
false, // enableCompression (default)
|
||||
true, // readOnly
|
||||
);
|
||||
restoreStdout();
|
||||
shared = { db, refCount: 0, ftsLoaded: false };
|
||||
dbCache.set(dbPath, shared);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
restoreStdout();
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
const isLockError =
|
||||
lastError.message.includes('Could not set lock') || lastError.message.includes('lock');
|
||||
if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
throw new Error(
|
||||
`LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` +
|
||||
`Retry later. (${lastError?.message || 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
shared.refCount++;
|
||||
const db = shared.db;
|
||||
|
||||
// Pre-create the full pool upfront so createConnection() (which silences
|
||||
// stdout) is never called lazily during active query execution.
|
||||
// Mark preWarmActive so the watchdog timer doesn't interfere.
|
||||
preWarmActive = true;
|
||||
const available: lbug.Connection[] = [];
|
||||
try {
|
||||
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
|
||||
available.push(createConnection(db));
|
||||
}
|
||||
} finally {
|
||||
preWarmActive = false;
|
||||
}
|
||||
|
||||
// Load FTS extension once per shared Database.
|
||||
// Done BEFORE pool registration so no concurrent checkout can grab
|
||||
// the connection while the async FTS load is in progress.
|
||||
if (!shared.ftsLoaded) {
|
||||
try {
|
||||
await available[0].query('LOAD EXTENSION fts');
|
||||
shared.ftsLoaded = true;
|
||||
} catch {
|
||||
// Extension may not be installed — FTS queries will fail gracefully
|
||||
}
|
||||
}
|
||||
|
||||
// Register pool entry only after all connections are pre-warmed and FTS is
|
||||
// loaded. Concurrent executeQuery calls see either "not initialized"
|
||||
// (and throw cleanly) or a fully ready pool — never a half-built one.
|
||||
pool.set(repoId, {
|
||||
db,
|
||||
available,
|
||||
checkedOut: 0,
|
||||
waiters: [],
|
||||
lastUsed: Date.now(),
|
||||
dbPath,
|
||||
closed: false,
|
||||
});
|
||||
ensureIdleTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a pool entry from a pre-existing Database object.
|
||||
*
|
||||
* Used in tests to avoid the writable→close→read-only cycle that crashes
|
||||
* on macOS due to N-API destructor segfaults. The pool adapter reuses
|
||||
* the core adapter's writable Database instead of opening a new read-only one.
|
||||
*
|
||||
* The Database is registered in the shared dbCache so closeOne() decrements
|
||||
* the refCount correctly. If the Database is already cached (e.g. another
|
||||
* repoId already injected it), the existing entry is reused.
|
||||
*/
|
||||
export async function initLbugWithDb(
|
||||
repoId: string,
|
||||
existingDb: lbug.Database,
|
||||
dbPath: string,
|
||||
): Promise<void> {
|
||||
const existing = pool.get(repoId);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// Register in dbCache with external: true so other initLbug() calls
|
||||
// for the same dbPath reuse this Database instead of trying to open
|
||||
// a new one (which would fail with a file lock error).
|
||||
// closeOne() respects the external flag and skips db.close().
|
||||
let shared = dbCache.get(dbPath);
|
||||
if (!shared) {
|
||||
shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true };
|
||||
dbCache.set(dbPath, shared);
|
||||
}
|
||||
shared.refCount++;
|
||||
|
||||
const available: lbug.Connection[] = [];
|
||||
preWarmActive = true;
|
||||
try {
|
||||
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
|
||||
available.push(createConnection(existingDb));
|
||||
}
|
||||
} finally {
|
||||
preWarmActive = false;
|
||||
}
|
||||
|
||||
// Load FTS extension if not already loaded on this Database
|
||||
try {
|
||||
await available[0].query('LOAD EXTENSION fts');
|
||||
} catch {
|
||||
// Extension may already be loaded or not installed
|
||||
}
|
||||
|
||||
pool.set(repoId, {
|
||||
db: existingDb,
|
||||
available,
|
||||
checkedOut: 0,
|
||||
waiters: [],
|
||||
lastUsed: Date.now(),
|
||||
dbPath,
|
||||
closed: false,
|
||||
});
|
||||
ensureIdleTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a connection from the pool.
|
||||
* Returns an available connection, or creates a new one if under the cap.
|
||||
* If all connections are busy and at cap, queues the caller until one is returned.
|
||||
*/
|
||||
function checkout(entry: PoolEntry): Promise<lbug.Connection> {
|
||||
// Fast path: grab an available connection
|
||||
if (entry.available.length > 0) {
|
||||
entry.checkedOut++;
|
||||
return Promise.resolve(entry.available.pop()!);
|
||||
}
|
||||
|
||||
// Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here
|
||||
// with fewer total connections, something leaked — surface the bug rather
|
||||
// than silently creating a connection (which would silence stdout mid-query).
|
||||
const totalConns = entry.available.length + entry.checkedOut;
|
||||
if (totalConns < MAX_CONNS_PER_REPO) {
|
||||
throw new Error(
|
||||
`Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` +
|
||||
`connections but found ${totalConns} (${entry.available.length} available, ` +
|
||||
`${entry.checkedOut} checked out)`,
|
||||
);
|
||||
}
|
||||
|
||||
// At capacity — queue the caller with a timeout.
|
||||
return new Promise<lbug.Connection>((resolve, reject) => {
|
||||
const waiter = (conn: lbug.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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a connection to the pool after use.
|
||||
* If the pool entry was closed while the connection was checked out (e.g.
|
||||
* LRU eviction), close the orphaned connection instead of returning it.
|
||||
* If there are queued waiters, hand the connection directly to the next one
|
||||
* instead of putting it back in the available array (avoids race conditions).
|
||||
*/
|
||||
function checkin(entry: PoolEntry, conn: lbug.Connection): void {
|
||||
if (entry.closed) {
|
||||
// Pool entry was deleted during checkout — close the orphaned connection
|
||||
conn.close().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (entry.waiters.length > 0) {
|
||||
// Hand directly to the next waiter — no intermediate available state
|
||||
const waiter = entry.waiters.shift()!;
|
||||
waiter(conn);
|
||||
} else {
|
||||
entry.checkedOut--;
|
||||
entry.available.push(conn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
|
||||
}
|
||||
|
||||
if (isWriteQuery(cypher)) {
|
||||
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
|
||||
}
|
||||
|
||||
entry.lastUsed = Date.now();
|
||||
|
||||
const conn = await checkout(entry);
|
||||
silenceStdout();
|
||||
activeQueryCount++;
|
||||
try {
|
||||
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 {
|
||||
activeQueryCount--;
|
||||
restoreStdout();
|
||||
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(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
|
||||
}
|
||||
|
||||
entry.lastUsed = Date.now();
|
||||
|
||||
const conn = await checkout(entry);
|
||||
silenceStdout();
|
||||
activeQueryCount++;
|
||||
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;
|
||||
} finally {
|
||||
activeQueryCount--;
|
||||
restoreStdout();
|
||||
checkin(entry, conn);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Close one or all repo pools.
|
||||
* If repoId is provided, close only that repo's connections.
|
||||
* If omitted, close all repos.
|
||||
*/
|
||||
export const closeLbug = async (repoId?: string): Promise<void> => {
|
||||
if (repoId) {
|
||||
closeOne(repoId);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of [...pool.keys()]) {
|
||||
closeOne(id);
|
||||
}
|
||||
|
||||
if (idleTimer) {
|
||||
clearInterval(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a specific repo's pool is active
|
||||
*/
|
||||
export const isLbugReady = (repoId: string): boolean => pool.has(repoId);
|
||||
|
||||
/** Regex to detect write operations in user-supplied Cypher queries.
|
||||
* Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
|
||||
* and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
|
||||
* read-only mode as defense-in-depth against write procedures. */
|
||||
export const CYPHER_WRITE_RE =
|
||||
/(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
|
||||
|
||||
/** Check if a Cypher query contains write operations */
|
||||
export function isWriteQuery(query: string): boolean {
|
||||
return CYPHER_WRITE_RE.test(query);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export const searchFTSFromLbug = async (
|
||||
// Use MCP connection pool via dynamic import
|
||||
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
|
||||
// The MCP pool supports multiple connections, but FTS is best run serially.
|
||||
const { executeQuery } = await import('../../mcp/core/lbug-adapter.js');
|
||||
const { executeQuery } = await import('../lbug/pool-adapter.js');
|
||||
const executor = (cypher: string) => executeQuery(repoId, cypher);
|
||||
fileResults = await queryFTSViaExecutor(executor, 'File', 'file_fts', query, limit);
|
||||
functionResults = await queryFTSViaExecutor(executor, 'Function', 'function_fts', query, limit);
|
||||
|
||||
@@ -46,6 +46,7 @@ const languageMap: Record<string, any> = {
|
||||
...(Kotlin ? { [SupportedLanguages.Kotlin]: Kotlin } : {}),
|
||||
[SupportedLanguages.PHP]: PHP.php_only,
|
||||
[SupportedLanguages.Ruby]: Ruby,
|
||||
[SupportedLanguages.Vue]: TypeScript.typescript,
|
||||
...(Dart ? { [SupportedLanguages.Dart]: Dart } : {}),
|
||||
...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}),
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Uses the MCP-style pooled lbug-adapter for connection management.
|
||||
*/
|
||||
|
||||
import { initLbug, executeQuery, closeLbug, touchRepo } from '../../mcp/core/lbug-adapter.js';
|
||||
import { initLbug, executeQuery, closeLbug, touchRepo } from '../lbug/pool-adapter.js';
|
||||
|
||||
const REPO_ID = '__wiki__';
|
||||
|
||||
|
||||
@@ -67,10 +67,12 @@ function buildHTML(
|
||||
pages: Record<string, string>,
|
||||
meta: Record<string, unknown> | null,
|
||||
): string {
|
||||
// Embed data as JSON inside the HTML
|
||||
const pagesJSON = JSON.stringify(pages);
|
||||
const treeJSON = JSON.stringify(moduleTree);
|
||||
const metaJSON = JSON.stringify(meta);
|
||||
// Embed data as JSON inside the HTML.
|
||||
// Escape </script> sequences so they don't prematurely close the <script> tag.
|
||||
const escScript = (s: string) => s.replace(/<\//g, '<\\/');
|
||||
const pagesJSON = escScript(JSON.stringify(pages));
|
||||
const treeJSON = escScript(JSON.stringify(moduleTree));
|
||||
const metaJSON = escScript(JSON.stringify(meta));
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
|
||||
@@ -152,11 +152,11 @@ export async function callLLM(
|
||||
messages,
|
||||
};
|
||||
|
||||
if (reasoning) {
|
||||
body.max_completion_tokens = config.maxTokens;
|
||||
// Do NOT include temperature, top_p, presence_penalty, frequency_penalty
|
||||
} else {
|
||||
body.max_tokens = config.maxTokens;
|
||||
// max_tokens is deprecated; use max_completion_tokens for all models
|
||||
body.max_completion_tokens = config.maxTokens;
|
||||
|
||||
// Only send temperature for non-Azure providers — some Azure models reject non-default values
|
||||
if (!reasoning && !azure && config.temperature !== undefined) {
|
||||
body.temperature = config.temperature;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getHttpDimensions,
|
||||
httpEmbedQuery,
|
||||
} from '../../core/embeddings/http-client.js';
|
||||
import { silenceStdout, restoreStdout, realStderrWrite } from '../../core/lbug/pool-adapter.js';
|
||||
|
||||
// Model config
|
||||
const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs';
|
||||
@@ -54,9 +55,9 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
||||
// Silence stdout and stderr during model load — ONNX Runtime and transformers.js
|
||||
// may write progress/init messages that corrupt MCP stdio protocol or produce
|
||||
// noisy warnings (e.g. node assignment to execution providers).
|
||||
const origStdout = process.stdout.write;
|
||||
const origStderr = process.stderr.write;
|
||||
process.stdout.write = (() => true) as any;
|
||||
// Use the centralized silenceStdout() to avoid conflicts with pool-adapter's
|
||||
// own stdout patching (independent patching caused restore-order bugs).
|
||||
silenceStdout();
|
||||
process.stderr.write = (() => true) as any;
|
||||
try {
|
||||
embedderInstance = await (pipeline as any)('feature-extraction', MODEL_ID, {
|
||||
@@ -64,8 +65,8 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
|
||||
dtype: 'fp32',
|
||||
});
|
||||
} finally {
|
||||
process.stdout.write = origStdout;
|
||||
process.stderr.write = origStderr;
|
||||
restoreStdout();
|
||||
process.stderr.write = realStderrWrite;
|
||||
}
|
||||
console.error(`GitNexus: Embedding model loaded (${device})`);
|
||||
return embedderInstance!;
|
||||
|
||||
@@ -1,574 +1,5 @@
|
||||
/**
|
||||
* LadybugDB Adapter (Connection Pool)
|
||||
*
|
||||
* Manages a pool of LadybugDB databases keyed by repoId, each with
|
||||
* multiple Connection objects for safe concurrent query execution.
|
||||
*
|
||||
* LadybugDB Connections are NOT thread-safe — a single Connection
|
||||
* segfaults if concurrent .query() calls hit it simultaneously.
|
||||
* This adapter provides a checkout/return connection pool so each
|
||||
* concurrent query gets its own Connection from the same Database.
|
||||
*
|
||||
* @see https://docs.ladybugdb.com/concurrency — multiple Connections
|
||||
* from the same Database is the officially supported concurrency pattern.
|
||||
* LadybugDB connection pool — re-exported from core.
|
||||
* Prefer importing from `../../core/lbug/pool-adapter.js` in new code.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import lbug from '@ladybugdb/core';
|
||||
|
||||
/** Per-repo pool: one Database, many Connections */
|
||||
interface PoolEntry {
|
||||
db: lbug.Database;
|
||||
/** Available connections ready for checkout */
|
||||
available: lbug.Connection[];
|
||||
/** Number of connections currently checked out */
|
||||
checkedOut: number;
|
||||
/** Queued waiters for when all connections are busy */
|
||||
waiters: Array<(conn: lbug.Connection) => void>;
|
||||
lastUsed: number;
|
||||
dbPath: string;
|
||||
/** Set to true when the pool entry is closed — checkin will close orphaned connections */
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
const pool = new Map<string, PoolEntry>();
|
||||
|
||||
/**
|
||||
* Shared Database cache keyed by resolved dbPath.
|
||||
* Multiple repoIds pointing to the same path share one native Database
|
||||
* object to avoid exhausting the buffer manager's mmap budget.
|
||||
*/
|
||||
interface SharedDB {
|
||||
db: lbug.Database;
|
||||
refCount: number;
|
||||
ftsLoaded: boolean;
|
||||
/** When true, closeOne skips db.close() — the Database is owned externally. */
|
||||
external?: boolean;
|
||||
}
|
||||
const dbCache = new Map<string, SharedDB>();
|
||||
|
||||
/** Max repos in the pool (LRU eviction) */
|
||||
const MAX_POOL_SIZE = 5;
|
||||
/** Idle timeout before closing a repo's connections */
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/** Max connections per repo (caps concurrent queries per repo) */
|
||||
const MAX_CONNS_PER_REPO = 8;
|
||||
|
||||
let idleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/** Saved real stdout.write — used to silence LadybugDB native output without race conditions */
|
||||
export const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
let stdoutSilenceCount = 0;
|
||||
/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
|
||||
let preWarmActive = false;
|
||||
|
||||
/**
|
||||
* Start the idle cleanup timer (runs every 60s)
|
||||
*/
|
||||
function ensureIdleTimer(): void {
|
||||
if (idleTimer) return;
|
||||
idleTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [repoId, entry] of pool) {
|
||||
if (now - entry.lastUsed > IDLE_TIMEOUT_MS && entry.checkedOut === 0) {
|
||||
closeOne(repoId);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
if (idleTimer && typeof idleTimer === 'object' && 'unref' in idleTimer) {
|
||||
(idleTimer as NodeJS.Timeout).unref();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch a repo to reset its idle timeout.
|
||||
* Call this during long-running operations to prevent the connection from being closed.
|
||||
*/
|
||||
export const touchRepo = (repoId: string): void => {
|
||||
const entry = pool.get(repoId);
|
||||
if (entry) {
|
||||
entry.lastUsed = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Evict the least-recently-used repo if pool is at capacity
|
||||
*/
|
||||
function evictLRU(): void {
|
||||
if (pool.size < MAX_POOL_SIZE) return;
|
||||
|
||||
let oldestId: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [id, entry] of pool) {
|
||||
if (entry.checkedOut === 0 && entry.lastUsed < oldestTime) {
|
||||
oldestTime = entry.lastUsed;
|
||||
oldestId = id;
|
||||
}
|
||||
}
|
||||
if (oldestId) {
|
||||
closeOne(oldestId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a repo from the pool, close its connections, and release its
|
||||
* shared Database ref. Only closes the Database when no other repoIds
|
||||
* reference it (refCount === 0).
|
||||
*/
|
||||
function closeOne(repoId: string): void {
|
||||
const entry = pool.get(repoId);
|
||||
if (!entry) return;
|
||||
|
||||
entry.closed = true;
|
||||
|
||||
// Close available connections — fire-and-forget with .catch() to prevent
|
||||
// unhandled rejections. Native close() returns Promise<void> but can crash
|
||||
// the N-API destructor on macOS/Windows; deferring to process exit lets
|
||||
// dangerouslyIgnoreUnhandledErrors absorb the crash.
|
||||
for (const conn of entry.available) {
|
||||
conn.close().catch(() => {});
|
||||
}
|
||||
entry.available.length = 0;
|
||||
|
||||
// Checked-out connections can't be closed here — they're in-flight.
|
||||
// The checkin() function detects entry.closed and closes them on return.
|
||||
|
||||
// Only close the Database when no other repoIds reference it.
|
||||
// External databases (injected via initLbugWithDb) are never closed here —
|
||||
// the core adapter owns them and handles their lifecycle.
|
||||
const shared = dbCache.get(entry.dbPath);
|
||||
if (shared) {
|
||||
shared.refCount--;
|
||||
if (shared.refCount === 0) {
|
||||
if (shared.external) {
|
||||
// External databases are owned by the core adapter — don't close
|
||||
// or remove from cache. Keep the entry so future initLbug() calls
|
||||
// for the same dbPath reuse it instead of hitting a file lock.
|
||||
shared.refCount = 0;
|
||||
} else {
|
||||
shared.db.close().catch(() => {});
|
||||
dbCache.delete(entry.dbPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pool.delete(repoId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Connection from a repo's Database.
|
||||
* Silences stdout to prevent native module output from corrupting MCP stdio.
|
||||
*/
|
||||
let activeQueryCount = 0;
|
||||
|
||||
function silenceStdout(): void {
|
||||
if (stdoutSilenceCount++ === 0) {
|
||||
process.stdout.write = (() => true) as any;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreStdout(): void {
|
||||
if (--stdoutSilenceCount <= 0) {
|
||||
stdoutSilenceCount = 0;
|
||||
process.stdout.write = realStdoutWrite;
|
||||
}
|
||||
}
|
||||
|
||||
// Safety watchdog: restore stdout if it gets stuck silenced (e.g. native crash
|
||||
// inside createConnection before restoreStdout runs).
|
||||
// Exempts active queries and pre-warm — these legitimately hold silence for
|
||||
// longer than 1 second (queries can take up to QUERY_TIMEOUT_MS = 30s).
|
||||
setInterval(() => {
|
||||
if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) {
|
||||
stdoutSilenceCount = 0;
|
||||
process.stdout.write = realStdoutWrite;
|
||||
}
|
||||
}, 1000).unref();
|
||||
|
||||
function createConnection(db: lbug.Database): lbug.Connection {
|
||||
silenceStdout();
|
||||
try {
|
||||
return new lbug.Connection(db);
|
||||
} finally {
|
||||
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;
|
||||
|
||||
/** Deduplicates concurrent initLbug calls for the same repoId */
|
||||
const initPromises = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Initialize (or reuse) a Database + connection pool for a specific repo.
|
||||
* Retries on lock errors (e.g., when `gitnexus analyze` is running).
|
||||
*
|
||||
* Concurrent calls for the same repoId are deduplicated — the second caller
|
||||
* awaits the first's in-progress init rather than starting a redundant one.
|
||||
*/
|
||||
export const initLbug = async (repoId: string, dbPath: string): Promise<void> => {
|
||||
const existing = pool.get(repoId);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// Deduplicate concurrent init calls for the same repoId —
|
||||
// prevents double-init race when multiple parallel tool calls
|
||||
// trigger initialization for the same repo simultaneously.
|
||||
const pending = initPromises.get(repoId);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = doInitLbug(repoId, dbPath);
|
||||
initPromises.set(repoId, promise);
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
initPromises.delete(repoId);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal init — creates DB, pre-warms connections, loads FTS, then registers pool.
|
||||
* Pool entry is registered LAST so concurrent executeQuery calls see either
|
||||
* "not initialized" (and throw) or a fully ready pool — never a half-built one.
|
||||
*/
|
||||
async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
|
||||
// Check if database exists
|
||||
try {
|
||||
await fs.stat(dbPath);
|
||||
} catch {
|
||||
throw new Error(`LadybugDB not found at ${dbPath}. Run: gitnexus analyze`);
|
||||
}
|
||||
|
||||
evictLRU();
|
||||
|
||||
// Reuse an existing native Database if another repoId already opened this path.
|
||||
// This prevents buffer manager exhaustion from multiple mmap regions on the same file.
|
||||
let shared = dbCache.get(dbPath);
|
||||
if (!shared) {
|
||||
// Open in read-only mode — MCP server never writes to the database.
|
||||
// This allows multiple MCP server instances to read concurrently, and
|
||||
// avoids lock conflicts when `gitnexus analyze` is writing.
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 1; attempt <= LOCK_RETRY_ATTEMPTS; attempt++) {
|
||||
silenceStdout();
|
||||
try {
|
||||
const db = new lbug.Database(
|
||||
dbPath,
|
||||
0, // bufferManagerSize (default)
|
||||
false, // enableCompression (default)
|
||||
true, // readOnly
|
||||
);
|
||||
restoreStdout();
|
||||
shared = { db, refCount: 0, ftsLoaded: false };
|
||||
dbCache.set(dbPath, shared);
|
||||
break;
|
||||
} catch (err: any) {
|
||||
restoreStdout();
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
const isLockError =
|
||||
lastError.message.includes('Could not set lock') || lastError.message.includes('lock');
|
||||
if (!isLockError || attempt === LOCK_RETRY_ATTEMPTS) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
throw new Error(
|
||||
`LadybugDB unavailable for ${repoId}. Another process may be rebuilding the index. ` +
|
||||
`Retry later. (${lastError?.message || 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
shared.refCount++;
|
||||
const db = shared.db;
|
||||
|
||||
// Pre-create the full pool upfront so createConnection() (which silences
|
||||
// stdout) is never called lazily during active query execution.
|
||||
// Mark preWarmActive so the watchdog timer doesn't interfere.
|
||||
preWarmActive = true;
|
||||
const available: lbug.Connection[] = [];
|
||||
try {
|
||||
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
|
||||
available.push(createConnection(db));
|
||||
}
|
||||
} finally {
|
||||
preWarmActive = false;
|
||||
}
|
||||
|
||||
// Load FTS extension once per shared Database.
|
||||
// Done BEFORE pool registration so no concurrent checkout can grab
|
||||
// the connection while the async FTS load is in progress.
|
||||
if (!shared.ftsLoaded) {
|
||||
try {
|
||||
await available[0].query('LOAD EXTENSION fts');
|
||||
shared.ftsLoaded = true;
|
||||
} catch {
|
||||
// Extension may not be installed — FTS queries will fail gracefully
|
||||
}
|
||||
}
|
||||
|
||||
// Register pool entry only after all connections are pre-warmed and FTS is
|
||||
// loaded. Concurrent executeQuery calls see either "not initialized"
|
||||
// (and throw cleanly) or a fully ready pool — never a half-built one.
|
||||
pool.set(repoId, {
|
||||
db,
|
||||
available,
|
||||
checkedOut: 0,
|
||||
waiters: [],
|
||||
lastUsed: Date.now(),
|
||||
dbPath,
|
||||
closed: false,
|
||||
});
|
||||
ensureIdleTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a pool entry from a pre-existing Database object.
|
||||
*
|
||||
* Used in tests to avoid the writable→close→read-only cycle that crashes
|
||||
* on macOS due to N-API destructor segfaults. The pool adapter reuses
|
||||
* the core adapter's writable Database instead of opening a new read-only one.
|
||||
*
|
||||
* The Database is registered in the shared dbCache so closeOne() decrements
|
||||
* the refCount correctly. If the Database is already cached (e.g. another
|
||||
* repoId already injected it), the existing entry is reused.
|
||||
*/
|
||||
export async function initLbugWithDb(
|
||||
repoId: string,
|
||||
existingDb: lbug.Database,
|
||||
dbPath: string,
|
||||
): Promise<void> {
|
||||
const existing = pool.get(repoId);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// Register in dbCache with external: true so other initLbug() calls
|
||||
// for the same dbPath reuse this Database instead of trying to open
|
||||
// a new one (which would fail with a file lock error).
|
||||
// closeOne() respects the external flag and skips db.close().
|
||||
let shared = dbCache.get(dbPath);
|
||||
if (!shared) {
|
||||
shared = { db: existingDb, refCount: 0, ftsLoaded: false, external: true };
|
||||
dbCache.set(dbPath, shared);
|
||||
}
|
||||
shared.refCount++;
|
||||
|
||||
const available: lbug.Connection[] = [];
|
||||
preWarmActive = true;
|
||||
try {
|
||||
for (let i = 0; i < MAX_CONNS_PER_REPO; i++) {
|
||||
available.push(createConnection(existingDb));
|
||||
}
|
||||
} finally {
|
||||
preWarmActive = false;
|
||||
}
|
||||
|
||||
// Load FTS extension if not already loaded on this Database
|
||||
try {
|
||||
await available[0].query('LOAD EXTENSION fts');
|
||||
} catch {
|
||||
// Extension may already be loaded or not installed
|
||||
}
|
||||
|
||||
pool.set(repoId, {
|
||||
db: existingDb,
|
||||
available,
|
||||
checkedOut: 0,
|
||||
waiters: [],
|
||||
lastUsed: Date.now(),
|
||||
dbPath,
|
||||
closed: false,
|
||||
});
|
||||
ensureIdleTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a connection from the pool.
|
||||
* Returns an available connection, or creates a new one if under the cap.
|
||||
* If all connections are busy and at cap, queues the caller until one is returned.
|
||||
*/
|
||||
function checkout(entry: PoolEntry): Promise<lbug.Connection> {
|
||||
// Fast path: grab an available connection
|
||||
if (entry.available.length > 0) {
|
||||
entry.checkedOut++;
|
||||
return Promise.resolve(entry.available.pop()!);
|
||||
}
|
||||
|
||||
// Pool was pre-warmed to MAX_CONNS_PER_REPO during init. If we're here
|
||||
// with fewer total connections, something leaked — surface the bug rather
|
||||
// than silently creating a connection (which would silence stdout mid-query).
|
||||
const totalConns = entry.available.length + entry.checkedOut;
|
||||
if (totalConns < MAX_CONNS_PER_REPO) {
|
||||
throw new Error(
|
||||
`Connection pool integrity error: expected ${MAX_CONNS_PER_REPO} ` +
|
||||
`connections but found ${totalConns} (${entry.available.length} available, ` +
|
||||
`${entry.checkedOut} checked out)`,
|
||||
);
|
||||
}
|
||||
|
||||
// At capacity — queue the caller with a timeout.
|
||||
return new Promise<lbug.Connection>((resolve, reject) => {
|
||||
const waiter = (conn: lbug.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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a connection to the pool after use.
|
||||
* If the pool entry was closed while the connection was checked out (e.g.
|
||||
* LRU eviction), close the orphaned connection instead of returning it.
|
||||
* If there are queued waiters, hand the connection directly to the next one
|
||||
* instead of putting it back in the available array (avoids race conditions).
|
||||
*/
|
||||
function checkin(entry: PoolEntry, conn: lbug.Connection): void {
|
||||
if (entry.closed) {
|
||||
// Pool entry was deleted during checkout — close the orphaned connection
|
||||
conn.close().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (entry.waiters.length > 0) {
|
||||
// Hand directly to the next waiter — no intermediate available state
|
||||
const waiter = entry.waiters.shift()!;
|
||||
waiter(conn);
|
||||
} else {
|
||||
entry.checkedOut--;
|
||||
entry.available.push(conn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
|
||||
}
|
||||
|
||||
if (isWriteQuery(cypher)) {
|
||||
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
|
||||
}
|
||||
|
||||
entry.lastUsed = Date.now();
|
||||
|
||||
const conn = await checkout(entry);
|
||||
silenceStdout();
|
||||
activeQueryCount++;
|
||||
try {
|
||||
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 {
|
||||
activeQueryCount--;
|
||||
restoreStdout();
|
||||
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(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
|
||||
}
|
||||
|
||||
entry.lastUsed = Date.now();
|
||||
|
||||
const conn = await checkout(entry);
|
||||
silenceStdout();
|
||||
activeQueryCount++;
|
||||
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;
|
||||
} finally {
|
||||
activeQueryCount--;
|
||||
restoreStdout();
|
||||
checkin(entry, conn);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Close one or all repo pools.
|
||||
* If repoId is provided, close only that repo's connections.
|
||||
* If omitted, close all repos.
|
||||
*/
|
||||
export const closeLbug = async (repoId?: string): Promise<void> => {
|
||||
if (repoId) {
|
||||
closeOne(repoId);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of [...pool.keys()]) {
|
||||
closeOne(id);
|
||||
}
|
||||
|
||||
if (idleTimer) {
|
||||
clearInterval(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a specific repo's pool is active
|
||||
*/
|
||||
export const isLbugReady = (repoId: string): boolean => pool.has(repoId);
|
||||
|
||||
/** Regex to detect write operations in user-supplied Cypher queries.
|
||||
* Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
|
||||
* and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
|
||||
* read-only mode as defense-in-depth against write procedures. */
|
||||
export const CYPHER_WRITE_RE =
|
||||
/(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
|
||||
|
||||
/** Check if a Cypher query contains write operations */
|
||||
export function isWriteQuery(query: string): boolean {
|
||||
return CYPHER_WRITE_RE.test(query);
|
||||
}
|
||||
export * from '../../core/lbug/pool-adapter.js';
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
closeLbug,
|
||||
isLbugReady,
|
||||
isWriteQuery,
|
||||
} from '../core/lbug-adapter.js';
|
||||
} from '../../core/lbug/pool-adapter.js';
|
||||
export { isWriteQuery };
|
||||
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
|
||||
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
cleanupOldKuzuFiles,
|
||||
type RegistryEntry,
|
||||
} from '../../storage/repo-manager.js';
|
||||
import { GroupService, type GroupToolPort } from '../../core/group/service.js';
|
||||
// AI context generation is CLI-only (gitnexus analyze)
|
||||
// import { generateAIContextFiles } from '../../cli/ai-context.js';
|
||||
|
||||
@@ -95,7 +96,9 @@ export const VALID_RELATION_TYPES = new Set([
|
||||
'IMPLEMENTS',
|
||||
'HAS_METHOD',
|
||||
'HAS_PROPERTY',
|
||||
'OVERRIDES',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES', // Legacy alias — dual-read for pre-rename indexes
|
||||
'METHOD_IMPLEMENTS',
|
||||
'ACCESSES',
|
||||
'HANDLES_ROUTE',
|
||||
'FETCHES',
|
||||
@@ -116,7 +119,8 @@ export const VALID_RELATION_TYPES = new Set([
|
||||
* CALLS / IMPORTS – direct, strongly-typed references → 0.9
|
||||
* EXTENDS – class hierarchy, statically verifiable → 0.85
|
||||
* IMPLEMENTS – interface contract, statically verifiable → 0.85
|
||||
* OVERRIDES – method override, statically verifiable → 0.85
|
||||
* METHOD_OVERRIDES – method override, statically verifiable → 0.85
|
||||
* METHOD_IMPLEMENTS – interface method implementation, statically verifiable → 0.85
|
||||
* HAS_METHOD – structural containment → 0.95
|
||||
* HAS_PROPERTY – structural containment → 0.95
|
||||
* ACCESSES – field read/write, may be indirect → 0.8
|
||||
@@ -128,7 +132,8 @@ export const IMPACT_RELATION_CONFIDENCE: Readonly<Record<string, number>> = {
|
||||
IMPORTS: 0.9,
|
||||
EXTENDS: 0.85,
|
||||
IMPLEMENTS: 0.85,
|
||||
OVERRIDES: 0.85,
|
||||
METHOD_OVERRIDES: 0.85,
|
||||
METHOD_IMPLEMENTS: 0.85,
|
||||
HAS_METHOD: 0.95,
|
||||
HAS_PROPERTY: 0.95,
|
||||
ACCESSES: 0.8,
|
||||
@@ -175,6 +180,28 @@ export class LocalBackend {
|
||||
private initializedRepos: Set<string> = new Set();
|
||||
private reinitPromises: Map<string, Promise<void>> = new Map();
|
||||
private lastStalenessCheck: Map<string, number> = new Map();
|
||||
private groupToolSvc: GroupService | null = null;
|
||||
|
||||
/**
|
||||
* Cross-repo group tools (CLI). Shares logic with MCP `group_*` handlers.
|
||||
*/
|
||||
getGroupService(): GroupService {
|
||||
if (!this.groupToolSvc) {
|
||||
const port: GroupToolPort = {
|
||||
resolveRepo: (p) => this.resolveRepo(p),
|
||||
impact: (r, p) => this.impact(r as RepoHandle, p),
|
||||
query: (r, p) => this.query(r as RepoHandle, p),
|
||||
impactByUid: (id, uid, d, o) => this.impactByUid(id, uid, d, o),
|
||||
};
|
||||
this.groupToolSvc = new GroupService(port);
|
||||
}
|
||||
return this.groupToolSvc;
|
||||
}
|
||||
|
||||
/** Close all pooled LadybugDB connections (CLI one-shot; optional for long-lived MCP). */
|
||||
async dispose(): Promise<void> {
|
||||
await closeLbug();
|
||||
}
|
||||
|
||||
// ─── Initialization ──────────────────────────────────────────────
|
||||
|
||||
@@ -430,6 +457,10 @@ export class LocalBackend {
|
||||
return this.listRepos();
|
||||
}
|
||||
|
||||
if (method.startsWith('group_')) {
|
||||
return this.handleGroupTool(method, params || {});
|
||||
}
|
||||
|
||||
// Resolve repo from optional param (re-reads registry on miss)
|
||||
const repo = await this.resolveRepo(params?.repo);
|
||||
|
||||
@@ -1173,7 +1204,7 @@ export class LocalBackend {
|
||||
repo.id,
|
||||
`
|
||||
MATCH (caller)-[r:CodeRelation]->(n {id: $symId})
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||||
RETURN r.type AS relType, caller.id AS uid, caller.name AS name, caller.filePath AS filePath, labels(caller)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
@@ -1263,7 +1294,7 @@ export class LocalBackend {
|
||||
repo.id,
|
||||
`
|
||||
MATCH (n {id: $symId})-[r:CodeRelation]->(target)
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'OVERRIDES', 'ACCESSES']
|
||||
WHERE r.type IN ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS', 'HAS_METHOD', 'HAS_PROPERTY', 'METHOD_OVERRIDES', 'OVERRIDES', 'METHOD_IMPLEMENTS', 'ACCESSES']
|
||||
RETURN r.type AS relType, target.id AS uid, target.name AS name, target.filePath AS filePath, labels(target)[0] AS kind
|
||||
LIMIT 30
|
||||
`,
|
||||
@@ -1302,16 +1333,53 @@ export class LocalBackend {
|
||||
return cats;
|
||||
};
|
||||
|
||||
// Method/Function/Constructor enrichment: fetch method-specific properties
|
||||
const symKind = isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2];
|
||||
const isMethodLike =
|
||||
symKind === 'Method' || symKind === 'Function' || symKind === 'Constructor';
|
||||
let methodMetadata: Record<string, unknown> | undefined;
|
||||
if (isMethodLike) {
|
||||
try {
|
||||
const metaRows = await executeParameterized(
|
||||
repo.id,
|
||||
`
|
||||
MATCH (n {id: $symId})
|
||||
RETURN n.visibility AS visibility, n.isStatic AS isStatic, n.isAbstract AS isAbstract,
|
||||
n.isFinal AS isFinal, n.isVirtual AS isVirtual, n.isOverride AS isOverride,
|
||||
n.isAsync AS isAsync, n.isPartial AS isPartial, n.returnType AS returnType,
|
||||
n.parameterCount AS parameterCount, n.isVariadic AS isVariadic,
|
||||
n.requiredParameterCount AS requiredParameterCount,
|
||||
n.parameterTypes AS parameterTypes, n.annotations AS annotations
|
||||
LIMIT 1
|
||||
`,
|
||||
{ symId },
|
||||
);
|
||||
if (metaRows.length > 0) {
|
||||
const row = metaRows[0];
|
||||
const meta: Record<string, unknown> = {};
|
||||
// Only include defined properties to distinguish "not applicable" from "not enriched"
|
||||
for (const key of Object.keys(row)) {
|
||||
const val = row[key];
|
||||
if (val !== null && val !== undefined) meta[key] = val;
|
||||
}
|
||||
if (Object.keys(meta).length > 0) methodMetadata = meta;
|
||||
}
|
||||
} catch {
|
||||
/* method metadata unavailable — omit silently */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'found',
|
||||
symbol: {
|
||||
uid: sym.id || sym[0],
|
||||
name: sym.name || sym[1],
|
||||
kind: isClassLike ? resolvedLabel || 'Class' : sym.type || sym[2],
|
||||
kind: symKind,
|
||||
filePath: sym.filePath || sym[3],
|
||||
startLine: sym.startLine || sym[4],
|
||||
endLine: sym.endLine || sym[5],
|
||||
...(include_content && (sym.content || sym[6]) ? { content: sym.content || sym[6] } : {}),
|
||||
...(methodMetadata ? { methodMetadata } : {}),
|
||||
},
|
||||
incoming: categorize(incomingRows),
|
||||
outgoing: categorize(outgoingRows),
|
||||
@@ -1845,18 +1913,37 @@ export class LocalBackend {
|
||||
|
||||
const { target, direction } = params;
|
||||
const maxDepth = params.maxDepth || 3;
|
||||
// Map legacy relation type names before filtering (backward compat for OVERRIDES → METHOD_OVERRIDES)
|
||||
const mappedRelTypes = params.relationTypes?.flatMap((t: string) =>
|
||||
t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t],
|
||||
);
|
||||
const rawRelTypes =
|
||||
params.relationTypes && params.relationTypes.length > 0
|
||||
? params.relationTypes.filter((t) => VALID_RELATION_TYPES.has(t))
|
||||
: ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
mappedRelTypes && mappedRelTypes.length > 0
|
||||
? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t))
|
||||
: [
|
||||
'CALLS',
|
||||
'IMPORTS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES',
|
||||
'METHOD_IMPLEMENTS',
|
||||
];
|
||||
const relationTypes =
|
||||
rawRelTypes.length > 0 ? rawRelTypes : ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS'];
|
||||
rawRelTypes.length > 0
|
||||
? rawRelTypes
|
||||
: [
|
||||
'CALLS',
|
||||
'IMPORTS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES',
|
||||
'METHOD_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}` : '';
|
||||
|
||||
// Resolve target by name, preferring Class/Interface over Constructor
|
||||
// (fix #480: Java class and constructor share the same name).
|
||||
// labels(n)[0] returns empty string in LadybugDB, so we use explicit
|
||||
@@ -1917,6 +2004,33 @@ export class LocalBackend {
|
||||
|
||||
if (!sym) return { error: `Target '${target}' not found` };
|
||||
|
||||
return this._runImpactBFS(repo, sym, symType, direction, {
|
||||
maxDepth,
|
||||
relationTypes,
|
||||
includeTests,
|
||||
minConfidence,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared BFS traversal for impact analysis (name-resolved or UID-resolved symbol).
|
||||
*/
|
||||
private async _runImpactBFS(
|
||||
repo: RepoHandle,
|
||||
sym: any,
|
||||
symType: string,
|
||||
direction: 'upstream' | 'downstream',
|
||||
opts: {
|
||||
maxDepth: number;
|
||||
relationTypes: string[];
|
||||
includeTests: boolean;
|
||||
minConfidence: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
|
||||
const relTypeFilter = relationTypes.map((t) => `'${t}'`).join(', ');
|
||||
const confidenceFilter = minConfidence > 0 ? ` AND r.confidence >= ${minConfidence}` : '';
|
||||
|
||||
const symId = sym.id || sym[0];
|
||||
|
||||
const impacted: any[] = [];
|
||||
@@ -2323,6 +2437,132 @@ export class LocalBackend {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* UID-based impact for cross-repo fan-out. Same result shape as `impact`.
|
||||
* Returns null if the repo is unknown, the UID is missing, or analysis fails.
|
||||
*/
|
||||
async impactByUid(
|
||||
repoId: string,
|
||||
uid: string,
|
||||
direction: string,
|
||||
opts: {
|
||||
maxDepth: number;
|
||||
relationTypes: string[];
|
||||
minConfidence: number;
|
||||
includeTests: boolean;
|
||||
},
|
||||
): Promise<any | null> {
|
||||
try {
|
||||
await this.refreshRepos();
|
||||
await this.ensureInitialized(repoId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repo = this.repos.get(repoId);
|
||||
if (!repo) return null;
|
||||
|
||||
const dir: 'upstream' | 'downstream' = direction === 'downstream' ? 'downstream' : 'upstream';
|
||||
|
||||
let rows: any[];
|
||||
try {
|
||||
rows = await executeParameterized(
|
||||
repoId,
|
||||
`MATCH (n) WHERE n.id = $uid
|
||||
RETURN n.id AS id, n.name AS name, n.filePath AS filePath, labels(n)[0] AS type
|
||||
LIMIT 1`,
|
||||
{ uid },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!rows?.length) return null;
|
||||
|
||||
const sym = rows[0];
|
||||
const labelRaw = sym.type ?? sym[3];
|
||||
const symType =
|
||||
typeof labelRaw === 'string' && labelRaw.trim().length > 0 ? labelRaw.trim() : '';
|
||||
|
||||
// Map legacy relation type names (backward compat for OVERRIDES → METHOD_OVERRIDES)
|
||||
const mappedRelTypes = opts.relationTypes?.flatMap((t: string) =>
|
||||
t === 'OVERRIDES' ? ['OVERRIDES', 'METHOD_OVERRIDES'] : [t],
|
||||
);
|
||||
const rawRelTypes =
|
||||
mappedRelTypes && mappedRelTypes.length > 0
|
||||
? mappedRelTypes.filter((t: string) => VALID_RELATION_TYPES.has(t))
|
||||
: [
|
||||
'CALLS',
|
||||
'IMPORTS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES',
|
||||
'METHOD_IMPLEMENTS',
|
||||
];
|
||||
const relationTypes =
|
||||
rawRelTypes.length > 0
|
||||
? rawRelTypes
|
||||
: [
|
||||
'CALLS',
|
||||
'IMPORTS',
|
||||
'EXTENDS',
|
||||
'IMPLEMENTS',
|
||||
'METHOD_OVERRIDES',
|
||||
'OVERRIDES',
|
||||
'METHOD_IMPLEMENTS',
|
||||
];
|
||||
|
||||
try {
|
||||
return await this._runImpactBFS(repo, sym, symType, dir, {
|
||||
maxDepth: opts.maxDepth,
|
||||
relationTypes,
|
||||
includeTests: opts.includeTests,
|
||||
minConfidence: opts.minConfidence,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleGroupTool(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'group_list':
|
||||
return this.groupList(params);
|
||||
case 'group_sync':
|
||||
return this.groupSync(params);
|
||||
case 'group_contracts':
|
||||
return this.groupContracts(params);
|
||||
case 'group_query':
|
||||
return this.groupQuery(params);
|
||||
case 'group_status':
|
||||
return this.groupStatus(params);
|
||||
default:
|
||||
throw new Error(`Unknown group tool: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async groupList(params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.getGroupService().groupList(params);
|
||||
}
|
||||
|
||||
private async groupSync(params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.getGroupService().groupSync(params);
|
||||
}
|
||||
|
||||
private async groupContracts(params: Record<string, unknown>): Promise<unknown> {
|
||||
return this.getGroupService().groupContracts(params);
|
||||
}
|
||||
|
||||
private async groupQuery(params: Record<string, unknown>): Promise<unknown> {
|
||||
await this.refreshRepos();
|
||||
return this.getGroupService().groupQuery(params);
|
||||
}
|
||||
|
||||
private async groupStatus(params: Record<string, unknown>): Promise<unknown> {
|
||||
await this.refreshRepos();
|
||||
return this.getGroupService().groupStatus(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Route nodes with their consumers in a single query.
|
||||
* Shared by routeMap and shapeCheck to avoid N+1 query patterns.
|
||||
|
||||
@@ -336,10 +336,10 @@ additional_node_types: "Multi-language: Struct, Enum, Macro, Typedef, Union, Nam
|
||||
|
||||
node_properties:
|
||||
common: "name (STRING), filePath (STRING), startLine (INT32), endLine (INT32)"
|
||||
Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
|
||||
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL)"
|
||||
Method: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL), visibility (STRING), isStatic (BOOL), isAbstract (BOOL), isFinal (BOOL), isVirtual (BOOL), isOverride (BOOL), isAsync (BOOL), isPartial (BOOL), requiredParameterCount (INT32), parameterTypes (STRING[]), annotations (STRING[])"
|
||||
Function: "parameterCount (INT32), returnType (STRING), isVariadic (BOOL), visibility (STRING), isStatic (BOOL), isAbstract (BOOL), isFinal (BOOL), isAsync (BOOL), parameterTypes (STRING[]), annotations (STRING[])"
|
||||
Property: "declaredType (STRING) — the field's type annotation (e.g., 'Address', 'City'). Used for field-access chain resolution."
|
||||
Constructor: "parameterCount (INT32)"
|
||||
Constructor: "parameterCount (INT32), visibility (STRING), isStatic (BOOL), parameterTypes (STRING[])"
|
||||
Community: "heuristicLabel (STRING), cohesion (DOUBLE), symbolCount (INT32), keywords (STRING[]), description (STRING), enrichedBy (STRING)"
|
||||
Process: "heuristicLabel (STRING), processType (STRING — 'intra_community' or 'cross_community'), stepCount (INT32), communities (STRING[]), entryPointId (STRING), terminalId (STRING)"
|
||||
|
||||
@@ -353,7 +353,8 @@ relationships:
|
||||
- HAS_METHOD: Class/Struct/Interface owns a Method
|
||||
- HAS_PROPERTY: Class/Struct/Interface owns a Property (field)
|
||||
- ACCESSES: Function/Method reads or writes a Property (reason: 'read' or 'write')
|
||||
- OVERRIDES: Method overrides another Method (MRO)
|
||||
- METHOD_OVERRIDES: Method overrides another Method (MRO)
|
||||
- METHOD_IMPLEMENTS: ConcreteMethod implements InterfaceMethod (matched by name + parameterTypes)
|
||||
- MEMBER_OF: Symbol belongs to community
|
||||
- STEP_IN_PROCESS: Symbol is step N in process
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user