Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b58ef53a8c | ||
|
|
4cd3ee3832 | ||
|
|
f40973a1ca | ||
|
|
4e362ba70a | ||
|
|
48f15a3bca | ||
|
|
8e76729750 | ||
|
|
fd4d4a3fee | ||
|
|
c8683d58fc | ||
|
|
de63418f7e | ||
|
|
7639308f65 | ||
|
|
68e4a5aece | ||
|
|
84564e09b8 | ||
|
|
bc98239fb4 | ||
|
|
608be7655d | ||
|
|
b486d04d75 | ||
|
|
28df98c997 | ||
|
|
96578aa4a8 | ||
|
|
a418c47e29 | ||
|
|
05ca80ea30 | ||
|
|
e55256ba53 | ||
|
|
9d91530f94 | ||
|
|
46e4c979c4 | ||
|
|
8fc32e6af9 | ||
|
|
e60e62f193 | ||
|
|
816ae5e66e | ||
|
|
4048f53e35 | ||
|
|
027340292f | ||
|
|
cbe5dac8b7 | ||
|
|
bf11269260 |
@@ -138,14 +138,15 @@ jobs:
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Find the latest successful CI run on main
|
||||
// Find recent successful CI runs on main (check several in case
|
||||
// the most recent artifact has expired).
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ci.yml',
|
||||
branch: 'main',
|
||||
status: 'success',
|
||||
per_page: 1,
|
||||
per_page: 5,
|
||||
});
|
||||
|
||||
if (runs.data.workflow_runs.length === 0) {
|
||||
@@ -154,32 +155,47 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const mainRunId = runs.data.workflow_runs[0].id;
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: mainRunId,
|
||||
});
|
||||
// Try each run until we find a downloadable test-reports artifact
|
||||
for (const run of runs.data.workflow_runs) {
|
||||
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: run.id,
|
||||
});
|
||||
|
||||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No test-reports artifact on main branch');
|
||||
return;
|
||||
const testReports = artifacts.data.artifacts.find(a => a.name === 'test-reports');
|
||||
if (!testReports) {
|
||||
core.info(`Run ${run.id}: no test-reports artifact, trying next`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: testReports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data));
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('dir', dest);
|
||||
return;
|
||||
} catch (err) {
|
||||
// 410 Gone means the artifact expired; try the next run
|
||||
if (err.status === 410 || err.response?.status === 410) {
|
||||
core.info(`Run ${run.id}: artifact expired, trying next`);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const zip = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: testReports.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
|
||||
const dest = path.join(process.env.RUNNER_TEMP, 'base-coverage');
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
fs.writeFileSync(path.join(dest, 'base.zip'), Buffer.from(zip.data));
|
||||
core.setOutput('found', 'true');
|
||||
core.setOutput('dir', dest);
|
||||
// All attempts exhausted — no usable base coverage
|
||||
core.setOutput('found', 'false');
|
||||
core.info('No downloadable test-reports artifact found on main (all expired or missing)');
|
||||
|
||||
- name: Extract base coverage
|
||||
if: steps.meta.outputs.skip != 'true' && steps.base-coverage.outputs.found == 'true'
|
||||
@@ -234,7 +250,7 @@ jobs:
|
||||
printf -v "${prefix}_BRANCH_COV" '%s' ""
|
||||
printf -v "${prefix}_FUNCS_COV" '%s' ""
|
||||
printf -v "${prefix}_LINES_COV" '%s' ""
|
||||
return 1
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -66,6 +66,6 @@ jobs:
|
||||
- '**/test/fixtures/**'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
@@ -123,7 +123,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write # push rc tag + marker
|
||||
# The default GITHUB_TOKEN cannot be granted `workflows: write`, so
|
||||
# tag pushes that reach a commit which modified `.github/workflows/**`
|
||||
# are rejected with: "refusing to allow a GitHub App to create or
|
||||
# update workflow ... without `workflows` permission". We pass a
|
||||
# fine-grained PAT (RELEASE_PUSH_TOKEN, scoped to this repo with
|
||||
# Contents: write + Workflows: write) to `actions/checkout` so that
|
||||
# the subsequent `git push --atomic` of the v-tag and rc marker
|
||||
# carries the PAT's identity. Job-level GITHUB_TOKEN keeps its
|
||||
# scoped permissions for everything else (npm provenance, etc.).
|
||||
contents: write # push rc tag + marker (via PAT)
|
||||
id-token: write # npm provenance
|
||||
outputs:
|
||||
vtag: ${{ steps.reltag.outputs.vtag }}
|
||||
@@ -132,6 +141,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
# Use the PAT so `origin` is preauthed for `git push`. Without
|
||||
# this the default GITHUB_TOKEN is wired into the remote, and a
|
||||
# workflows-touching tag push is rejected — see the permissions
|
||||
# block above.
|
||||
token: ${{ secrets.RELEASE_PUSH_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
|
||||
@@ -53,6 +53,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build image (load locally for scan)
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.image.dockerfile }}
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
exit-code: '0'
|
||||
|
||||
- name: Upload to Security tab
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
sarif_file: trivy-${{ matrix.image.name }}.sarif
|
||||
category: trivy-${{ matrix.image.name }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
- name: Upload SARIF
|
||||
uses: github/codeql-action/upload-sarif@0daab03d71ff584ef619d027a3fd9146679c5d84 # v3.35.3
|
||||
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
sarif_file: zizmor.sarif
|
||||
category: zizmor
|
||||
|
||||
@@ -109,6 +109,8 @@ That's it. This indexes the codebase, installs agent skills, registers Claude Co
|
||||
|
||||
To configure MCP for your editor, run `npx gitnexus setup` once — or set it up manually below.
|
||||
|
||||
> **Faster install (no C++ toolchain needed):** set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before `npm install -g gitnexus` to skip the native `tree-sitter-dart` and `tree-sitter-proto` builds. Dart/Proto files won't be parsed, but install completes in seconds without `python3`/`make`/`g++`. Strict `=1` only — any other value falls through to the rebuild.
|
||||
|
||||
### MCP Setup
|
||||
|
||||
`gitnexus setup` auto-detects your editors and writes the correct global MCP config. You only need to run it once.
|
||||
@@ -138,6 +140,8 @@ Built by the community — not officially maintained, but worth checking out.
|
||||
|
||||
If you prefer manual configuration:
|
||||
|
||||
> **Recommended for fastest startup:** install gitnexus globally (`npm i -g gitnexus`) and run `gitnexus setup` — this writes an absolute-path MCP config that bypasses `npx` entirely. The pinned-`npx` snippets below are a quickstart fallback; on a cold cache the `npx` install can exceed Claude Code's `MCP_TIMEOUT` default (~30s).
|
||||
|
||||
**Claude Code** (full support — MCP + skills + hooks):
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ services:
|
||||
- ${WORKSPACE_DIR:-./workspace}:/workspace:ro
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-fsSI', 'http://localhost:4747/api/heartbeat']
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:4747/api/health']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Using GitNexus across Apache Thrift microservices
|
||||
|
||||
## When to use this guide
|
||||
|
||||
Use this guide when several repositories communicate through Apache Thrift and you want GitNexus to trace impact across provider and consumer boundaries. The walkthrough assumes each service is indexed on its own, then joined through a GitNexus group.
|
||||
|
||||
This is not a framework integration guide. GitNexus reads portable Thrift IDL and common Java generated-code shapes. Framework-specific wiring, service discovery, deployment metadata, and private annotations belong outside the open-source core.
|
||||
|
||||
## Mental model
|
||||
|
||||
- `.thrift` files define the canonical service contract. A method in an IDL service becomes a stable contract id in the form `thrift::<namespace>.<Service>/<Method>`.
|
||||
- Service wildcard ids in the form `thrift::<namespace>.<Service>/*` are supported as manifest and matching fallback forms when a service-level link is needed.
|
||||
- Java generated-code usage points GitNexus toward implementation and call sites. Providers commonly implement generated `Service.Iface`; consumers commonly hold or construct generated service interfaces or clients.
|
||||
- Group sync matches provider and consumer contracts with the same id, then cross-repo impact can hop through those links.
|
||||
- Framework-specific wiring should be modeled by extractor plugins, manifest links, or downstream integrations rather than hard-coded into core Thrift support.
|
||||
|
||||
## Fictional IDL
|
||||
|
||||
```thrift
|
||||
namespace java billing.v1
|
||||
|
||||
struct PlaceOrderRequest {
|
||||
1: string orderId
|
||||
2: double amount
|
||||
}
|
||||
|
||||
struct PlaceOrderResponse {
|
||||
1: bool accepted
|
||||
}
|
||||
|
||||
struct GetOrderRequest {
|
||||
1: string orderId
|
||||
}
|
||||
|
||||
struct GetOrderResponse {
|
||||
1: string orderId
|
||||
2: string status
|
||||
}
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
GetOrderResponse GetOrder(1: GetOrderRequest request)
|
||||
}
|
||||
```
|
||||
|
||||
The service methods above produce canonical ids:
|
||||
|
||||
- `thrift::billing.v1.OrderService/PlaceOrder`
|
||||
- `thrift::billing.v1.OrderService/GetOrder`
|
||||
- `thrift::billing.v1.OrderService/*` as a service-level manifest or matching fallback form
|
||||
|
||||
## Java provider example
|
||||
|
||||
Generated Java code usually exposes an `Iface` interface for the service. A provider implementation can be detected when it implements that generated interface.
|
||||
|
||||
```java
|
||||
package example.billing;
|
||||
|
||||
import billing.v1.GetOrderRequest;
|
||||
import billing.v1.GetOrderResponse;
|
||||
import billing.v1.OrderService;
|
||||
import billing.v1.PlaceOrderRequest;
|
||||
import billing.v1.PlaceOrderResponse;
|
||||
|
||||
public final class OrderServiceHandler implements OrderService.Iface {
|
||||
@Override
|
||||
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
||||
return new PlaceOrderResponse(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GetOrderResponse GetOrder(GetOrderRequest request) {
|
||||
return new GetOrderResponse(request.getOrderId(), "CREATED");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With the IDL available, GitNexus can connect the implementation to `thrift::billing.v1.OrderService/PlaceOrder` and `thrift::billing.v1.OrderService/GetOrder`.
|
||||
|
||||
## Java consumer examples
|
||||
|
||||
Consumers are strongest when Java usage can be tied back to the IDL namespace and service.
|
||||
|
||||
```java
|
||||
package example.checkout;
|
||||
|
||||
import billing.v1.OrderService;
|
||||
import billing.v1.PlaceOrderRequest;
|
||||
|
||||
public final class CheckoutWorkflow {
|
||||
private final OrderService.Iface orders;
|
||||
|
||||
public CheckoutWorkflow(OrderService.Iface orders) {
|
||||
this.orders = orders;
|
||||
}
|
||||
|
||||
public void submit(String orderId) throws Exception {
|
||||
orders.PlaceOrder(new PlaceOrderRequest(orderId, 42.0));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Some generated-code styles use the generated service type directly while keeping enough IDL context through imports and method calls.
|
||||
|
||||
```java
|
||||
package example.reporting;
|
||||
|
||||
import billing.v1.GetOrderRequest;
|
||||
import billing.v1.OrderService;
|
||||
|
||||
public final class OrderLookup {
|
||||
private final OrderService.Client client;
|
||||
|
||||
public OrderLookup(OrderService.Client client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public String status(String orderId) throws Exception {
|
||||
return client.GetOrder(new GetOrderRequest(orderId)).getStatus();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When IDL context is missing, GitNexus may still emit a weaker consumer signal for generated `Iface` or `Client` shapes, but confidence is lower.
|
||||
|
||||
## Group configuration
|
||||
|
||||
New group configs enable Thrift contract detection by default. Keep `detect.thrift: true`
|
||||
when a group should scan for Thrift contracts, or set it to `false` to skip Thrift
|
||||
extraction for that group.
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
name: billing-platform
|
||||
description: Fictional services connected by Apache Thrift
|
||||
|
||||
repos:
|
||||
checkout: checkout-service
|
||||
billing: billing-service
|
||||
|
||||
links: []
|
||||
|
||||
detect:
|
||||
http: true
|
||||
grpc: false
|
||||
thrift: true
|
||||
topics: false
|
||||
shared_libs: true
|
||||
```
|
||||
|
||||
To disable Thrift extraction explicitly:
|
||||
|
||||
```yaml
|
||||
detect:
|
||||
thrift: false
|
||||
```
|
||||
|
||||
After indexing each member repository, run group sync to extract contracts and write cross-repo links:
|
||||
|
||||
```bash
|
||||
npx gitnexus group sync billing-platform
|
||||
```
|
||||
|
||||
## Manifest escape hatch
|
||||
|
||||
Use manifest links when automatic extraction cannot see a provider or consumer, or when generated code is wrapped behind an abstraction. Write the contract without the `thrift::` prefix; GitNexus canonicalizes it to the full Thrift contract id.
|
||||
|
||||
```yaml
|
||||
links:
|
||||
- from: checkout
|
||||
to: billing
|
||||
type: thrift
|
||||
contract: billing.v1.OrderService/PlaceOrder
|
||||
role: consumer
|
||||
```
|
||||
|
||||
GitNexus canonicalizes that manifest entry to `thrift::billing.v1.OrderService/PlaceOrder` and uses it to connect the two repositories.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Java detection currently targets v1 generated-code patterns.
|
||||
- Maven and POM dependency coordinates are not used for inference.
|
||||
- Framework-specific annotations and service discovery metadata are ignored by open-source Thrift extraction.
|
||||
- Ambiguous same-name services are skipped instead of guessed.
|
||||
- Java consumers without IDL context are lower confidence and limited to generated `Iface` and `Client` shapes.
|
||||
@@ -67,6 +67,52 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// MCP-reachable code: forbid stdout-corrupting writes. The MCP stdio
|
||||
// transport writes JSON-RPC frames to stdout; per the spec, the server
|
||||
// MUST NOT write anything to stdout that is not a valid MCP message.
|
||||
// Diagnostics must go to stderr (console.error). Direct process.stdout.write
|
||||
// bypasses the gate and is also forbidden in these dirs.
|
||||
// cli/mcp.ts is included here even though it lives under cli/ — it is the
|
||||
// MCP entrypoint and inherits stricter discipline than the rest of cli/.
|
||||
{
|
||||
files: [
|
||||
'gitnexus/src/mcp/**/*.ts',
|
||||
'gitnexus/src/core/lbug/**/*.ts',
|
||||
'gitnexus/src/core/embeddings/**/*.ts',
|
||||
'gitnexus/src/core/tree-sitter/**/*.ts',
|
||||
'gitnexus/src/cli/mcp.ts',
|
||||
],
|
||||
rules: {
|
||||
'no-console': ['error', { allow: ['error'] }],
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector:
|
||||
"MemberExpression[object.type='MemberExpression'][object.object.name='process'][object.property.name='stdout'][property.name='write']",
|
||||
message:
|
||||
'Direct process.stdout.write is forbidden in MCP-reachable code. Route diagnostics through console.error or process.stderr.write — the MCP stdio transport owns stdout for JSON-RPC frames.',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.object.name='process'][callee.object.property.name='stdout'][callee.property.name='write']",
|
||||
message:
|
||||
'Direct process.stdout.write is forbidden in MCP-reachable code. Route diagnostics through console.error or process.stderr.write — the MCP stdio transport owns stdout for JSON-RPC frames.',
|
||||
},
|
||||
{
|
||||
// Catches the canonical destructuring shape:
|
||||
// const { write } = process.stdout;
|
||||
// (and any other ObjectPattern destructure rooted at process.stdout)
|
||||
// which would otherwise capture a reference to the original write
|
||||
// and bypass the sentinel.
|
||||
selector:
|
||||
"VariableDeclarator[init.type='MemberExpression'][init.object.name='process'][init.property.name='stdout'] > ObjectPattern",
|
||||
message:
|
||||
'Destructuring process.stdout is forbidden in MCP-reachable code — bypasses the sentinel. Use process.stderr.write for diagnostics.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// React-specific rules for gitnexus-web
|
||||
{
|
||||
files: ['gitnexus-web/src/**/*.{ts,tsx}'],
|
||||
@@ -79,6 +125,27 @@ export default [
|
||||
},
|
||||
},
|
||||
|
||||
// Prevent direct conn.close() / db.close() in the LadybugDB adapter (#1376).
|
||||
// All close operations must go through safeClose() so the WAL is always
|
||||
// flushed before the connection is released. The sole authorised call site
|
||||
// inside safeClose itself uses an eslint-disable-next-line override.
|
||||
{
|
||||
files: ['gitnexus/src/core/lbug/lbug-adapter.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='conn'][callee.property.name='close']",
|
||||
message: 'Use safeClose() instead of calling conn.close() directly (#1376).',
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.object.name='db'][callee.property.name='close']",
|
||||
message: 'Use safeClose() instead of calling db.close() directly (#1376).',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Disable formatting rules (prettier handles those)
|
||||
prettierConfig,
|
||||
];
|
||||
|
||||
Generated
+49
-49
@@ -8,15 +8,15 @@
|
||||
"name": "gitnexus",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.3.27",
|
||||
"@langchain/core": "^1.1.41",
|
||||
"@langchain/anthropic": "^1.3.28",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.16.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
@@ -33,7 +33,7 @@
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
@@ -57,7 +57,7 @@
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.0.10",
|
||||
@@ -132,9 +132,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.10.tgz",
|
||||
"integrity": "sha512-KyOb19eytNSELkmdqzZZUXWCU25byIlOld5qVFg0RYdS0T3tt7jeDByxk9hIAC73frclD8GKrHttr0SUjKCCdQ==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -685,9 +685,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
|
||||
"integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1396,9 +1396,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/anthropic": {
|
||||
"version": "1.3.27",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.27.tgz",
|
||||
"integrity": "sha512-A0pWKIMIhgF01z3ILA8uAbZ6ZR2H8UQP2Ww8Ofq5DtHp36uJkQgrNCdS+q9pUVJJ87eq5dEdFkpjrjmH4fVkfQ==",
|
||||
"version": "1.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.3.28.tgz",
|
||||
"integrity": "sha512-gOF8oXJL8xDdYes2KXNI9vFm/9TldBBBHOjuCdt27kganVaQKzLvTw5kV6R4mjbnFagV5CWteNH7APLZYCpdwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.90.0",
|
||||
@@ -1408,13 +1408,13 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.41"
|
||||
"@langchain/core": "^1.1.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.1.42",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.42.tgz",
|
||||
"integrity": "sha512-d0tN96BrwPMryYyWR9VfyAntSivn7EQrZCe5Kpxum93tcjTXbKKmKvItFec8AluQt88iTcmAJrahUZUNfzGwTA==",
|
||||
"version": "1.1.44",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.44.tgz",
|
||||
"integrity": "sha512-RePW1IjGCHr9ua2vcby3aE8mOOz3EnwDZxMEGbNDT91kf14eqkJqxDXvaZFviGdcN9DTrxM5RPQNAHmwSm4tbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
@@ -3401,12 +3401,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@@ -4563,13 +4563,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
@@ -5478,28 +5478,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "29.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
|
||||
"integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^5.1.5",
|
||||
"@asamuzakjp/dom-selector": "^7.0.6",
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.2.7",
|
||||
"parse5": "^8.0.0",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.24.5",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
@@ -6032,9 +6032,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.7",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
|
||||
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -7452,13 +7452,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
|
||||
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
"entities": "^8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
@@ -7727,24 +7727,24 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.5"
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
|
||||
@@ -19,15 +19,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"@langchain/anthropic": "^1.3.27",
|
||||
"@langchain/core": "^1.1.41",
|
||||
"@langchain/anthropic": "^1.3.28",
|
||||
"@langchain/core": "^1.1.44",
|
||||
"@langchain/google-genai": "^2.1.28",
|
||||
"@langchain/langgraph": "^1.2.9",
|
||||
"@langchain/ollama": "^1.2.6",
|
||||
"@langchain/openai": "^1.4.5",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"axios": "^1.13.2",
|
||||
"axios": "^1.16.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.2",
|
||||
"graphology": "^0.26.0",
|
||||
@@ -43,7 +43,7 @@
|
||||
"mnemonist": "^0.39.0",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
@@ -67,7 +67,7 @@
|
||||
"@vercel/node": "^5.5.16",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.0.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^8.0.10",
|
||||
|
||||
Generated
+12
-12
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.4-rc.86",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.4-rc.86",
|
||||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -3018,12 +3018,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
|
||||
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -3486,9 +3486,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -3892,9 +3892,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
|
||||
"integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.3",
|
||||
"version": "1.6.4-rc.86",
|
||||
"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",
|
||||
@@ -44,6 +44,7 @@
|
||||
"dev": "tsx watch src/cli/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run test/unit",
|
||||
"pretest:integration": "node scripts/build.js",
|
||||
"test:integration": "vitest run test/integration",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -3,6 +3,17 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Opt-out: skip the native rebuild entirely. Dart parsing becomes
|
||||
// unavailable but `npm install gitnexus` finishes much faster on machines
|
||||
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
|
||||
// (read as a string), and any other value all fall through to the rebuild.
|
||||
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
|
||||
console.warn(
|
||||
'[tree-sitter-dart] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Dart parsing will be unavailable until reinstalled without the env var.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const dartDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-dart');
|
||||
const bindingGyp = path.join(dartDir, 'binding.gyp');
|
||||
const bindingNode = path.join(dartDir, 'build', 'Release', 'tree_sitter_dart_binding.node');
|
||||
|
||||
@@ -34,6 +34,17 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Opt-out: skip the native rebuild entirely. Proto parsing becomes
|
||||
// unavailable but `npm install gitnexus` finishes much faster on machines
|
||||
// without a C++ toolchain. Strict `=== '1'` only — '=true', '=yes', '=0'
|
||||
// (read as a string), and any other value all fall through to the rebuild.
|
||||
if (process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1') {
|
||||
console.warn(
|
||||
'[tree-sitter-proto] Skipping build (GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1). Proto parsing will be unavailable until reinstalled without the env var.',
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const protoDir = path.join(__dirname, '..', 'node_modules', 'tree-sitter-proto');
|
||||
const bindingGyp = path.join(protoDir, 'binding.gyp');
|
||||
const bindingNode = path.join(protoDir, 'build', 'Release', 'tree_sitter_proto_binding.node');
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import { runFullAnalysis } from '../core/run-analyze.js';
|
||||
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
|
||||
import { warnMissingOptionalGrammars } from './optional-grammars.js';
|
||||
import { glob } from 'glob';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
// Capture stderr.write at module load BEFORE anything (LadybugDB native
|
||||
@@ -95,7 +97,14 @@ function ensureHeap(): boolean {
|
||||
|
||||
export interface AnalyzeOptions {
|
||||
force?: boolean;
|
||||
embeddings?: boolean;
|
||||
/**
|
||||
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
|
||||
* - `undefined` when the flag is omitted
|
||||
* - `true` when passed without an argument (use default 50K node cap)
|
||||
* - a string when passed with an argument (`--embeddings 0` disables the
|
||||
* cap, `--embeddings <n>` uses `<n>` as the cap)
|
||||
*/
|
||||
embeddings?: boolean | string;
|
||||
/**
|
||||
* Explicitly drop existing embeddings on rebuild instead of preserving
|
||||
* them. Without this flag, a routine `analyze` keeps any embeddings
|
||||
@@ -167,6 +176,25 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
);
|
||||
}
|
||||
|
||||
// Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap
|
||||
// (0 disables the cap entirely). Validated up here so failures match the
|
||||
// sibling-validation pattern (exit before bar.start() — otherwise
|
||||
// process.exit() leaves the progress bar's hidden cursor uncleared).
|
||||
let embeddingsNodeLimit: number | undefined;
|
||||
if (typeof options?.embeddings === 'string') {
|
||||
const parsed = Number(options.embeddings);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
console.error(
|
||||
` --embeddings expects a non-negative integer (got "${options.embeddings}"). ` +
|
||||
`Pass 0 to disable the safety cap, or omit the value to keep the default.\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
embeddingsNodeLimit = parsed;
|
||||
}
|
||||
const embeddingsEnabled = !!options?.embeddings;
|
||||
|
||||
const setPositiveEnv = (
|
||||
optionName: string,
|
||||
envName: string,
|
||||
@@ -247,6 +275,30 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
);
|
||||
}
|
||||
|
||||
// If the target repo contains files an optional grammar would parse but
|
||||
// that grammar's native binding is absent, warn before analysis so users
|
||||
// learn why those files end up unparsed instead of silently getting a
|
||||
// degraded index.
|
||||
try {
|
||||
const matches = await glob(['**/*.dart', '**/*.proto'], {
|
||||
cwd: repoPath,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
|
||||
dot: false,
|
||||
nodir: true,
|
||||
absolute: false,
|
||||
});
|
||||
if (matches.length > 0) {
|
||||
const present = new Set<string>();
|
||||
for (const m of matches) {
|
||||
const ext = path.extname(m).toLowerCase();
|
||||
if (ext) present.add(ext);
|
||||
}
|
||||
warnMissingOptionalGrammars({ context: 'analyze', relevantExtensions: present });
|
||||
}
|
||||
} catch {
|
||||
// Best-effort warning \u2014 never block analyze on the precheck.
|
||||
}
|
||||
|
||||
// KuzuDB migration cleanup is handled by runFullAnalysis internally.
|
||||
// Note: --skills is handled after runFullAnalysis using the returned pipelineResult.
|
||||
|
||||
@@ -338,7 +390,8 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
|
||||
// needs a fresh pipelineResult. Has no bearing on the registry
|
||||
// collision guard (see allowDuplicateName below).
|
||||
force: options?.force || options?.skills,
|
||||
embeddings: options?.embeddings,
|
||||
embeddings: embeddingsEnabled,
|
||||
embeddingsNodeLimit,
|
||||
dropEmbeddings: options?.dropEmbeddings,
|
||||
skipGit: options?.skipGit,
|
||||
skipAgentsMd: options?.skipAgentsMd,
|
||||
|
||||
@@ -23,7 +23,11 @@ program
|
||||
.command('analyze [path]')
|
||||
.description('Index a repository (full analysis)')
|
||||
.option('-f, --force', 'Force full re-index even if up to date')
|
||||
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
|
||||
.option(
|
||||
'--embeddings [limit]',
|
||||
'Enable embedding generation for semantic search (off by default). ' +
|
||||
'Optional [limit] overrides the 50,000-node safety cap; pass 0 to disable the cap entirely.',
|
||||
)
|
||||
.option(
|
||||
'--drop-embeddings',
|
||||
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
|
||||
|
||||
+45
-13
@@ -4,23 +4,55 @@
|
||||
* Starts the MCP server in standalone mode.
|
||||
* Loads all indexed repos from the global registry.
|
||||
* No longer depends on cwd — works from any directory.
|
||||
*
|
||||
* IMPORTANT: this module's static-import closure is intentionally tiny
|
||||
* (one chain: `mcp/stdio-context.js` → `mcp/stdio-capture.js`, which is a
|
||||
* leaf with zero non-`node:` imports). All heavy backend modules
|
||||
* (`startMCPServer`, `LocalBackend`, `warnMissingOptionalGrammars`) load
|
||||
* via `await import(...)` AFTER `installGlobalStdoutSentinel()` runs.
|
||||
*
|
||||
* This closes the ESM-evaluation-order window where native init banners
|
||||
* from `@ladybugdb/core` (or any future heavy import) could reach raw
|
||||
* stdout before the sentinel exists. Codex's adversarial review on
|
||||
* PR #1383 found that even with the sentinel-install call as the first
|
||||
* statement of `mcpCommand`, ESM evaluates static imports of THIS module
|
||||
* before the function body runs — so any native side effects during
|
||||
* those imports happen before the sentinel can intercept them.
|
||||
*
|
||||
* If you find yourself adding a static `import` to this file, ask
|
||||
* whether the imported module (or anything it transitively imports)
|
||||
* touches `process.stdout` or loads a native binding at module init. If
|
||||
* either is true, switch it to a dynamic `await import(...)` inside
|
||||
* `mcpCommand` after the sentinel install. The regression test at
|
||||
* `gitnexus/test/integration/mcp/import-closure.test.ts` enforces this.
|
||||
*/
|
||||
|
||||
import { startMCPServer } from '../mcp/server.js';
|
||||
import { LocalBackend } from '../mcp/local/local-backend.js';
|
||||
import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js';
|
||||
|
||||
export const mcpCommand = async () => {
|
||||
// Prevent unhandled errors from crashing the MCP server process.
|
||||
// LadybugDB lock conflicts and transient errors should degrade gracefully.
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error(`GitNexus MCP: uncaught exception — ${err.message}`);
|
||||
// Process is in an undefined state after uncaughtException — exit after flushing
|
||||
setTimeout(() => process.exit(1), 100);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||
console.error(`GitNexus MCP: unhandled rejection — ${msg}`);
|
||||
});
|
||||
// Install the global stdout sentinel as the very first thing — before
|
||||
// ANY other module loads. The static-import closure above is leaf-only
|
||||
// (stdio-context → stdio-capture, zero non-`node:` deps), so this is
|
||||
// also the first chance any code in this process has to write to stdout.
|
||||
installGlobalStdoutSentinel();
|
||||
|
||||
// uncaughtException/unhandledRejection handlers are owned by
|
||||
// startMCPServer (gitnexus/src/mcp/server.ts) so the server's shutdown
|
||||
// path runs cleanly with full stack traces. Registering duplicates here
|
||||
// would only produce noisy double-logging on the same exception.
|
||||
|
||||
// Now safe to dynamically import the heavy backend modules. Anything
|
||||
// they emit to stdout during evaluation will route through the sentinel.
|
||||
const [{ startMCPServer }, { LocalBackend }] = await Promise.all([
|
||||
import('../mcp/server.js'),
|
||||
import('../mcp/local/local-backend.js'),
|
||||
]);
|
||||
|
||||
// Missing-optional-grammar warnings are intentionally NOT emitted here.
|
||||
// `gitnexus analyze` already warns at index time, filtered by the repo's
|
||||
// actual extensions, and a repo can only be served by MCP after analyze
|
||||
// has run. Repeating an unconditional warning at every MCP startup is
|
||||
// pure noise for users whose indexed repos don't use Dart/Proto.
|
||||
|
||||
// Initialize multi-repo backend from registry.
|
||||
// The server starts even with 0 repos — tools call refreshRepos() lazily,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Optional grammar availability check.
|
||||
*
|
||||
* tree-sitter-dart and tree-sitter-proto are optionalDependencies that
|
||||
* require a `node-gyp rebuild` at install time. The build can be skipped
|
||||
* via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can
|
||||
* silently soft-fail when the C++ toolchain is missing.
|
||||
*
|
||||
* Either path produces the same observable: the .node binding is absent
|
||||
* at runtime. This helper detects that condition and surfaces a single
|
||||
* stderr line per missing grammar so users learn why .dart/.proto support
|
||||
* is unavailable instead of silently getting a degraded index.
|
||||
*/
|
||||
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
interface OptionalGrammar {
|
||||
/** Display name in warnings */
|
||||
name: string;
|
||||
/** Module name to require.resolve */
|
||||
pkg: string;
|
||||
/** File extensions this grammar parses */
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
const OPTIONAL_GRAMMARS: OptionalGrammar[] = [
|
||||
{ name: 'tree-sitter-dart', pkg: 'tree-sitter-dart', extensions: ['.dart'] },
|
||||
{ name: 'tree-sitter-proto', pkg: 'tree-sitter-proto', extensions: ['.proto'] },
|
||||
];
|
||||
|
||||
export interface MissingGrammar {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of optional grammars whose native binding cannot be
|
||||
* loaded. Actually `require()`s the package — `require.resolve` would
|
||||
* locate the entry path even when the `.node` binding is absent (the
|
||||
* `file:` package directory is installed regardless of postinstall
|
||||
* outcome), giving false negatives for the exact users we want to warn:
|
||||
* those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose
|
||||
* native rebuild soft-failed for missing toolchain.
|
||||
*
|
||||
* Node's module cache memoizes `require()` for us — calling this multiple
|
||||
* times is cheap. The catch distinguishes "missing" (MODULE_NOT_FOUND or
|
||||
* the typical node-gyp-build "could not find any binding" pattern) from
|
||||
* "broken" (SyntaxError, EACCES, native crash). Broken bindings surface a
|
||||
* separate stderr line so users get an actionable message instead of a
|
||||
* misleading "reinstall" hint.
|
||||
*/
|
||||
export function detectMissingOptionalGrammars(): MissingGrammar[] {
|
||||
const missing: MissingGrammar[] = [];
|
||||
for (const g of OPTIONAL_GRAMMARS) {
|
||||
try {
|
||||
_require(g.pkg);
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const looksMissing =
|
||||
code === 'MODULE_NOT_FOUND' ||
|
||||
code === 'ERR_MODULE_NOT_FOUND' ||
|
||||
/could not find|no native build|prebuilds/i.test(msg);
|
||||
if (!looksMissing) {
|
||||
// Present but broken — surface so the user doesn't get a misleading
|
||||
// "reinstall" recovery message that wouldn't actually help.
|
||||
console.error(
|
||||
`GitNexus: optional grammar "${g.name}" is installed but failed to load (${msg.slice(0, 200)}). ${g.extensions.join('/')} files will not be parsed.`,
|
||||
);
|
||||
}
|
||||
missing.push({ name: g.name, extensions: g.extensions });
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a one-line stderr warning for each missing grammar. Safe to call
|
||||
* unconditionally — silent if all grammars are present.
|
||||
*
|
||||
* `relevantExtensions`, if provided, filters the warning to grammars whose
|
||||
* extensions appear in the set (e.g. an analyze run can pass the set of
|
||||
* extensions actually present in the target repo so users without any
|
||||
* .dart/.proto files don't see noise).
|
||||
*/
|
||||
export function warnMissingOptionalGrammars(opts?: {
|
||||
context?: string;
|
||||
relevantExtensions?: ReadonlySet<string>;
|
||||
}): void {
|
||||
const missing = detectMissingOptionalGrammars();
|
||||
if (missing.length === 0) return;
|
||||
const ctx = opts?.context ? ` [${opts.context}]` : '';
|
||||
for (const g of missing) {
|
||||
if (opts?.relevantExtensions && !g.extensions.some((e) => opts.relevantExtensions!.has(e))) {
|
||||
continue;
|
||||
}
|
||||
console.error(
|
||||
`GitNexus${ctx}: optional grammar "${g.name}" is unavailable — ${g.extensions.join('/')} files will not be parsed. Reinstall without GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (and ensure python3, make, g++) to enable.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { execFile, execFileSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import { promisify } from 'util';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { glob } from 'glob';
|
||||
@@ -20,6 +21,21 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Pin the npx fallback to the installed version. Reason: setup.ts writes
|
||||
// a config that persists in the user's editor and is invoked on every MCP
|
||||
// connect. Pinning to the installed version means subsequent invocations
|
||||
// skip the npm-registry metadata roundtrip (and stay reproducible until
|
||||
// the user upgrades). Static configs and READMEs intentionally use
|
||||
// `gitnexus@latest` since they're quickstart docs, not persisted state.
|
||||
const _require = createRequire(import.meta.url);
|
||||
const _pkg = _require('../../package.json') as { version?: unknown };
|
||||
if (typeof _pkg.version !== 'string' || !_pkg.version) {
|
||||
throw new Error(
|
||||
'gitnexus/package.json#version is missing or not a string — cannot generate MCP fallback config.',
|
||||
);
|
||||
}
|
||||
const NPX_REF = `gitnexus@${_pkg.version}`;
|
||||
|
||||
interface SetupResult {
|
||||
configured: string[];
|
||||
skipped: string[];
|
||||
@@ -62,8 +78,10 @@ function resolveGitnexusBin(): string | null {
|
||||
* The MCP server entry for all editors.
|
||||
*
|
||||
* 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).
|
||||
* `npx -y gitnexus@<version>` (cold-cache install of native deps can take
|
||||
* >60 s, exceeding Claude Code's 30 s MCP connection timeout). The fallback
|
||||
* version is read from gitnexus/package.json#version at module load so the
|
||||
* persisted user config matches the installed package.
|
||||
*
|
||||
* 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`.
|
||||
@@ -79,12 +97,12 @@ function getMcpEntry() {
|
||||
if (process.platform === 'win32') {
|
||||
return {
|
||||
command: 'cmd',
|
||||
args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'],
|
||||
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: 'npx',
|
||||
args: ['-y', 'gitnexus@latest', 'mcp'],
|
||||
args: ['-y', NPX_REF, 'mcp'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,9 +118,9 @@ function getOpenCodeMcpEntry() {
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'] };
|
||||
return { type: 'local', command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'] };
|
||||
}
|
||||
return { type: 'local', command: ['npx', '-y', 'gitnexus@latest', 'mcp'] };
|
||||
return { type: 'local', command: ['npx', '-y', NPX_REF, 'mcp'] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -604,7 +622,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
result.configured.push(
|
||||
`OpenCode skills (${installed.length} skills → ~/.config/opencode/skill/)`,
|
||||
`OpenCode skills (${installed.length} skills → ~/.config/opencode/skills/)`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -30,6 +30,38 @@ export interface EmbeddingMode {
|
||||
shouldLoadCache: boolean;
|
||||
}
|
||||
|
||||
/** Default safety cap on graph node count for embedding generation. */
|
||||
export const DEFAULT_EMBEDDING_NODE_LIMIT = 50_000;
|
||||
|
||||
export interface EmbeddingCapDecision {
|
||||
/** True when the node-count cap blocks generation for this graph. */
|
||||
skipForCap: boolean;
|
||||
/** True when the user explicitly disabled the cap (`--embeddings 0`). */
|
||||
capDisabled: boolean;
|
||||
/** Effective node limit applied (`0` means disabled). */
|
||||
nodeLimit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the node-count safety cap blocks embedding generation.
|
||||
*
|
||||
* - `embeddingsNodeLimit === undefined` → use {@link DEFAULT_EMBEDDING_NODE_LIMIT}
|
||||
* - `embeddingsNodeLimit === 0` → cap disabled, generation always proceeds
|
||||
* - any positive integer → custom cap (skip if `nodeCount > limit`)
|
||||
*
|
||||
* Lives in `embedding-mode.ts` (not `run-analyze.ts`) so the branching
|
||||
* contract is unit-testable without spinning up LadybugDB or the pipeline.
|
||||
*/
|
||||
export function deriveEmbeddingCap(
|
||||
nodeCount: number,
|
||||
embeddingsNodeLimit: number | undefined,
|
||||
): EmbeddingCapDecision {
|
||||
const nodeLimit = embeddingsNodeLimit ?? DEFAULT_EMBEDDING_NODE_LIMIT;
|
||||
const capDisabled = nodeLimit === 0;
|
||||
const skipForCap = !capDisabled && nodeCount > nodeLimit;
|
||||
return { skipForCap, capDisabled, nodeLimit };
|
||||
}
|
||||
|
||||
export function deriveEmbeddingMode(
|
||||
options: EmbeddingModeInput,
|
||||
existingEmbeddingCount: number,
|
||||
|
||||
@@ -166,7 +166,7 @@ export const initEmbedder = async (
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev) {
|
||||
console.log(`🧠 Loading embedding model: ${finalConfig.modelId}`);
|
||||
console.error(`🧠 Loading embedding model: ${finalConfig.modelId}`);
|
||||
}
|
||||
|
||||
const progressCallback = onProgress
|
||||
@@ -192,13 +192,13 @@ export const initEmbedder = async (
|
||||
for (const device of devicesToTry) {
|
||||
try {
|
||||
if (isDev && device === 'dml') {
|
||||
console.log('🔧 Trying DirectML (DirectX12) GPU backend...');
|
||||
console.error('🔧 Trying DirectML (DirectX12) GPU backend...');
|
||||
} else if (isDev && device === 'cuda') {
|
||||
console.log('🔧 Trying CUDA GPU backend...');
|
||||
console.error('🔧 Trying CUDA GPU backend...');
|
||||
} else if (isDev && device === 'cpu') {
|
||||
console.log('🔧 Using CPU backend...');
|
||||
console.error('🔧 Using CPU backend...');
|
||||
} else if (isDev && device === 'wasm') {
|
||||
console.log('🔧 Using WASM backend (slower)...');
|
||||
console.error('🔧 Using WASM backend (slower)...');
|
||||
}
|
||||
|
||||
embedderInstance = await (pipeline as any)('feature-extraction', finalConfig.modelId, {
|
||||
@@ -221,15 +221,15 @@ export const initEmbedder = async (
|
||||
: device === 'cuda'
|
||||
? 'GPU (CUDA)'
|
||||
: device.toUpperCase();
|
||||
console.log(`✅ Using ${label} backend`);
|
||||
console.log('✅ Embedding model loaded successfully');
|
||||
console.error(`✅ Using ${label} backend`);
|
||||
console.error('✅ Embedding model loaded successfully');
|
||||
}
|
||||
|
||||
return embedderInstance!;
|
||||
} catch (deviceError) {
|
||||
if (isDev && (device === 'cuda' || device === 'dml')) {
|
||||
const gpuType = device === 'dml' ? 'DirectML' : 'CUDA';
|
||||
console.log(`⚠️ ${gpuType} not available, falling back to CPU...`);
|
||||
console.error(`⚠️ ${gpuType} not available, falling back to CPU...`);
|
||||
}
|
||||
// Continue to next device in list
|
||||
if (device === devicesToTry[devicesToTry.length - 1]) {
|
||||
|
||||
@@ -157,7 +157,7 @@ const queryEmbeddableNodes = async (
|
||||
}
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
console.warn(`Query for ${label} nodes failed:`, error);
|
||||
console.error(`Query for ${label} nodes failed:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ const createVectorIndex = async (
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isDev) {
|
||||
console.warn('Vector index creation warning:', error);
|
||||
console.error('Vector index creation warning:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -256,7 +256,7 @@ export const runEmbeddingPipeline = async (
|
||||
|
||||
try {
|
||||
const vectorAvailable = await ensureVectorExtensionAvailable();
|
||||
if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage);
|
||||
if (!vectorAvailable && isDev) console.error(vectorUnavailableMessage);
|
||||
|
||||
// Phase 1: Load embedding model
|
||||
onProgress({
|
||||
@@ -283,7 +283,7 @@ export const runEmbeddingPipeline = async (
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log('🔍 Querying embeddable nodes...');
|
||||
console.error('🔍 Querying embeddable nodes...');
|
||||
}
|
||||
|
||||
// Phase 2: Query embeddable nodes
|
||||
@@ -325,7 +325,7 @@ export const runEmbeddingPipeline = async (
|
||||
// (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern)
|
||||
if (staleNodeIds.length > 0) {
|
||||
if (isDev) {
|
||||
console.log(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
|
||||
console.error(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
|
||||
}
|
||||
try {
|
||||
await executeWithReusedStatement(
|
||||
@@ -346,7 +346,7 @@ export const runEmbeddingPipeline = async (
|
||||
}
|
||||
|
||||
if (isDev) {
|
||||
console.log(
|
||||
console.error(
|
||||
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`,
|
||||
);
|
||||
}
|
||||
@@ -355,7 +355,7 @@ export const runEmbeddingPipeline = async (
|
||||
const totalNodes = nodes.length;
|
||||
|
||||
if (isDev) {
|
||||
console.log(`📊 Found ${totalNodes} embeddable nodes`);
|
||||
console.error(`📊 Found ${totalNodes} embeddable nodes`);
|
||||
}
|
||||
|
||||
if (totalNodes === 0) {
|
||||
@@ -442,7 +442,7 @@ export const runEmbeddingPipeline = async (
|
||||
);
|
||||
} catch (chunkErr) {
|
||||
if (isDev) {
|
||||
console.warn(
|
||||
console.error(
|
||||
`⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`,
|
||||
chunkErr,
|
||||
);
|
||||
@@ -520,7 +520,7 @@ export const runEmbeddingPipeline = async (
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log('📇 Creating vector index...');
|
||||
console.error('📇 Creating vector index...');
|
||||
}
|
||||
|
||||
const vectorIndexReady = await createVectorIndex(executeQuery);
|
||||
@@ -533,7 +533,7 @@ export const runEmbeddingPipeline = async (
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
console.log(
|
||||
console.error(
|
||||
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import lbug from '@ladybugdb/core';
|
||||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
@@ -24,7 +24,7 @@ import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
* - `.shadow` — non-blocking concurrent checkpoint sidecar (added in
|
||||
* LadybugDB 0.15.4); same pairing constraint as `.wal`.
|
||||
*
|
||||
* `bridge-db` writes to a `bridge.lbug.tmp` file and then atomically renames
|
||||
* `bridge-db` writes to a `bridge.lbug.tmp.<random>` file and then atomically renames
|
||||
* it into place. The rename only moves the main file; sidecars must be
|
||||
* cleaned up explicitly or the next writer trips the database-id check.
|
||||
*/
|
||||
@@ -41,6 +41,26 @@ async function removeLbugFile(basePath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all stale `bridge.lbug.tmp.*` files (and their sidecars) from a
|
||||
* group directory. With randomBytes-based temp names, a crashed writeBridge
|
||||
* leaves behind a uniquely-named tmp file that no future run will target by
|
||||
* name — so we glob for the prefix and clean up everything matching.
|
||||
*/
|
||||
async function cleanStaleBridgeTmpFiles(groupDir: string): Promise<void> {
|
||||
try {
|
||||
const entries = await fsp.readdir(groupDir);
|
||||
const staleBases = entries.filter(
|
||||
(e) => e.startsWith('bridge.lbug.tmp.') && !LBUG_SIDECAR_SUFFIXES.some((s) => e.endsWith(s)),
|
||||
);
|
||||
for (const name of staleBases) {
|
||||
await removeLbugFile(path.join(groupDir, name));
|
||||
}
|
||||
} catch {
|
||||
/* best-effort: directory may not exist yet */
|
||||
}
|
||||
}
|
||||
|
||||
export function contractNodeId(
|
||||
repo: string,
|
||||
contractId: string,
|
||||
@@ -276,7 +296,7 @@ export async function retryRename(src: string, dst: string, attempts = 3): Promi
|
||||
|
||||
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
|
||||
const target = path.join(groupDir, 'meta.json');
|
||||
const tmp = `${target}.tmp.${Date.now()}`;
|
||||
const tmp = `${target}.tmp.${randomBytes(8).toString('hex')}`;
|
||||
await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8');
|
||||
// Use retryRename for consistency with writeBridge's atomic swap — on
|
||||
// Windows a concurrent reader can cause EBUSY/EPERM even on a tiny
|
||||
@@ -346,7 +366,7 @@ export async function writeBridge(
|
||||
const crossLinks = dedupeCrossLinks(input.crossLinks);
|
||||
|
||||
const finalPath = path.join(groupDir, 'bridge.lbug');
|
||||
const tmpPath = path.join(groupDir, 'bridge.lbug.tmp');
|
||||
const tmpPath = path.join(groupDir, `bridge.lbug.tmp.${randomBytes(8).toString('hex')}`);
|
||||
const bakPath = path.join(groupDir, 'bridge.lbug.bak');
|
||||
|
||||
const report: WriteBridgeReport = {
|
||||
@@ -366,11 +386,12 @@ export async function writeBridge(
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up any leftover tmp main file AND its `.wal` / `.shadow` sidecars.
|
||||
// LadybugDB 0.16.0 rejects opening a database whose sidecars belong to a
|
||||
// different database instance (database-id check), so any stale sidecar
|
||||
// from a crashed previous run will fail the next writeBridge.
|
||||
await removeLbugFile(tmpPath);
|
||||
// Clean up stale tmp files left behind by previously crashed writeBridge
|
||||
// runs. With randomBytes-based names each run picks a unique path, so
|
||||
// the old fixed-name `removeLbugFile(tmpPath)` was a no-op — stale
|
||||
// artifacts accumulated. The glob-based helper finds *all* leftover
|
||||
// `bridge.lbug.tmp.*` entries and removes them (including sidecars).
|
||||
await cleanStaleBridgeTmpFiles(groupDir);
|
||||
|
||||
// 1. Create temp DB, insert all data.
|
||||
//
|
||||
|
||||
@@ -4,12 +4,13 @@ import type { GroupConfig, GroupManifestLink, ContractType, ContractRole } from
|
||||
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_CONTRACT_TYPES: ContractType[] = ['http', 'grpc', 'thrift', 'topic', 'lib', 'custom'];
|
||||
const VALID_ROLES: ContractRole[] = ['provider', 'consumer'];
|
||||
|
||||
const DEFAULT_DETECT = {
|
||||
http: true,
|
||||
grpc: true,
|
||||
thrift: true,
|
||||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
|
||||
@@ -177,7 +177,7 @@ export class ManifestExtractor {
|
||||
|
||||
// NOTE: All lookups use EXACT equality on the relevant name field and
|
||||
// deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS
|
||||
// for fuzzy matching (plus an unconditional ".proto" fallback for gRPC)
|
||||
// for fuzzy matching (plus an unconditional IDL file fallback for gRPC)
|
||||
// which produced silent false positives: e.g. manifest "/orders" would
|
||||
// match "/suborders", and a gRPC manifest entry in a repo with any
|
||||
// .proto file would attach to a random proto symbol.
|
||||
@@ -225,16 +225,21 @@ export class ManifestExtractor {
|
||||
LIMIT 1`,
|
||||
{ contract: link.contract },
|
||||
);
|
||||
} else if (link.type === 'grpc') {
|
||||
} else if (link.type === 'grpc' || link.type === 'thrift') {
|
||||
// Contract is "Service/Method" or just "Service" (or package.Service
|
||||
// variants). Prefer matching by method name when present, otherwise
|
||||
// by service name. NO .proto path fallback — that's guaranteed to
|
||||
// return a wrong symbol in any repo with more than one proto file.
|
||||
// by service name. Thrift generated Java classes often use
|
||||
// package.Service in manifests while graph Class/Interface names are
|
||||
// stored as bare Service, so strip the package prefix for thrift
|
||||
// service-name lookups. NO IDL path fallback — that's guaranteed to
|
||||
// return a wrong symbol in any repo with more than one IDL file.
|
||||
// Label filters scope lookups: methods → Function|Method, services
|
||||
// → Class|Interface (no label match = no silent wrong hits on
|
||||
// File/Variable nodes that happen to share the name).
|
||||
const parts = link.contract.split('/');
|
||||
const serviceName = parts[0]?.trim() ?? '';
|
||||
const rawServiceName = parts[0]?.trim() ?? '';
|
||||
const serviceName =
|
||||
link.type === 'thrift' ? (rawServiceName.split('.').pop() ?? '') : rawServiceName;
|
||||
const methodName = parts[1]?.trim() ?? '';
|
||||
if (methodName) {
|
||||
rows = await executor(
|
||||
@@ -344,6 +349,8 @@ export class ManifestExtractor {
|
||||
}
|
||||
case 'grpc':
|
||||
return `grpc::${contract}`;
|
||||
case 'thrift':
|
||||
return `thrift::${contract}`;
|
||||
case 'topic':
|
||||
return `topic::${contract}`;
|
||||
case 'lib':
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { glob } from 'glob';
|
||||
import Parser from 'tree-sitter';
|
||||
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
|
||||
import type { ExtractedContract, RepoHandle } from '../types.js';
|
||||
import { readSafe } from './fs-utils.js';
|
||||
import {
|
||||
getPluginForFile,
|
||||
THRIFT_SCAN_GLOB,
|
||||
type ThriftDetection,
|
||||
} from './thrift-patterns/index.js';
|
||||
|
||||
export interface ThriftServiceInfo {
|
||||
namespace: string;
|
||||
serviceName: string;
|
||||
methods: string[];
|
||||
thriftPath: string;
|
||||
}
|
||||
|
||||
export interface ThriftContext {
|
||||
namespacesByThrift: Map<string, string>;
|
||||
servicesByName: Map<string, ThriftServiceInfo[]>;
|
||||
}
|
||||
|
||||
function normalizeThriftPath(rel: string): string {
|
||||
return rel.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
export function thriftMethodContractId(
|
||||
namespace: string,
|
||||
serviceName: string,
|
||||
methodName: string,
|
||||
): string {
|
||||
const prefix = namespace ? `${namespace}.${serviceName}` : serviceName;
|
||||
return `thrift::${prefix}/${methodName}`;
|
||||
}
|
||||
|
||||
export function thriftServiceContractId(namespace: string, serviceName: string): string {
|
||||
const prefix = namespace ? `${namespace}.${serviceName}` : serviceName;
|
||||
return `thrift::${prefix}/*`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace Thrift comments and string literals with spaces while preserving
|
||||
* newlines and character offsets. Service block scanning can then count braces
|
||||
* without being confused by examples or comments inside the IDL.
|
||||
*/
|
||||
function stripThriftCommentsAndStrings(content: string): string {
|
||||
const out = new Array<string>(content.length);
|
||||
let i = 0;
|
||||
|
||||
while (i < content.length) {
|
||||
const ch = content[i];
|
||||
const next = content[i + 1];
|
||||
|
||||
if (ch === '/' && next === '/') {
|
||||
out[i] = ' ';
|
||||
out[i + 1] = ' ';
|
||||
i += 2;
|
||||
while (i < content.length && content[i] !== '\n') {
|
||||
out[i] = content[i] === '\r' ? '\r' : ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '#') {
|
||||
out[i] = ' ';
|
||||
i++;
|
||||
while (i < content.length && content[i] !== '\n') {
|
||||
out[i] = content[i] === '\r' ? '\r' : ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '/' && next === '*') {
|
||||
out[i] = ' ';
|
||||
out[i + 1] = ' ';
|
||||
i += 2;
|
||||
while (i < content.length) {
|
||||
if (content[i] === '*' && content[i + 1] === '/') {
|
||||
out[i] = ' ';
|
||||
out[i + 1] = ' ';
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
out[i] = content[i] === '\n' || content[i] === '\r' ? content[i] : ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
const quote = ch;
|
||||
out[i] = ' ';
|
||||
i++;
|
||||
while (i < content.length) {
|
||||
const c = content[i];
|
||||
if (c === '\\' && i + 1 < content.length) {
|
||||
out[i] = ' ';
|
||||
out[i + 1] = ' ';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === quote) {
|
||||
out[i] = ' ';
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
out[i] = c === '\n' || c === '\r' ? c : ' ';
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out[i] = ch;
|
||||
i++;
|
||||
}
|
||||
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function extractNamespace(sanitizedContent: string): string {
|
||||
const namespaces: Array<{ language: string; namespace: string }> = [];
|
||||
const namespaceRe = /^\s*namespace\s+([A-Za-z_*][\w.*-]*)\s+([A-Za-z_][\w.]*)\s*$/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = namespaceRe.exec(sanitizedContent)) !== null) {
|
||||
namespaces.push({ language: match[1], namespace: match[2] });
|
||||
}
|
||||
|
||||
return (
|
||||
namespaces.find((entry) => entry.language === 'java')?.namespace ??
|
||||
namespaces[0]?.namespace ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function extractServiceBlocks(sanitizedContent: string): Array<{ name: string; body: string }> {
|
||||
const results: Array<{ name: string; body: string }> = [];
|
||||
const headerRe = /service\s+([A-Za-z_]\w*)\s*(?:extends\s+[A-Za-z_][\w.]*)?\s*\{/g;
|
||||
let headerMatch: RegExpExecArray | null;
|
||||
|
||||
while ((headerMatch = headerRe.exec(sanitizedContent)) !== null) {
|
||||
const serviceName = headerMatch[1];
|
||||
const bodyStart = headerMatch.index + headerMatch[0].length;
|
||||
let depth = 1;
|
||||
let pos = bodyStart;
|
||||
|
||||
while (pos < sanitizedContent.length && depth > 0) {
|
||||
const ch = sanitizedContent[pos];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (depth !== 0) continue;
|
||||
|
||||
results.push({
|
||||
name: serviceName,
|
||||
body: sanitizedContent.slice(bodyStart, pos - 1),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractMethods(sanitizedServiceBody: string): string[] {
|
||||
const methods: string[] = [];
|
||||
const methodRe =
|
||||
/(?:^|[;,\n\r])\s*(?:oneway\s+)?[A-Za-z_][\w.]*(?:\s*<[^(){};]*>)?\s+([A-Za-z_]\w*)\s*\(/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = methodRe.exec(sanitizedServiceBody)) !== null) {
|
||||
methods.push(match[1]);
|
||||
}
|
||||
|
||||
return methods;
|
||||
}
|
||||
|
||||
function thriftSourceScanSymbolUid(
|
||||
contractId: string,
|
||||
role: 'provider' | 'consumer',
|
||||
filePath: string,
|
||||
symbolName: string,
|
||||
): string {
|
||||
const contractKey = contractId.startsWith('thrift::')
|
||||
? contractId.slice('thrift::'.length)
|
||||
: contractId;
|
||||
return ['source-scan::thrift', role, contractKey, normalizeThriftPath(filePath), symbolName].join(
|
||||
'::',
|
||||
);
|
||||
}
|
||||
|
||||
function makeContract(
|
||||
cid: string,
|
||||
role: 'provider' | 'consumer',
|
||||
filePath: string,
|
||||
symbolName: string,
|
||||
confidence: number,
|
||||
meta: Record<string, unknown>,
|
||||
): ExtractedContract {
|
||||
return {
|
||||
contractId: cid,
|
||||
type: 'thrift',
|
||||
role,
|
||||
symbolUid: thriftSourceScanSymbolUid(cid, role, filePath, symbolName),
|
||||
symbolRef: { filePath: normalizeThriftPath(filePath), name: symbolName },
|
||||
symbolName,
|
||||
confidence,
|
||||
meta: { ...meta, extractionStrategy: 'source_scan' },
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildThriftContext(repoPath: string): Promise<ThriftContext> {
|
||||
const thriftFiles = await glob('**/*.thrift', {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
});
|
||||
const namespacesByThrift = new Map<string, string>();
|
||||
const servicesByName = new Map<string, ThriftServiceInfo[]>();
|
||||
|
||||
for (const rel of thriftFiles) {
|
||||
const thriftPath = normalizeThriftPath(rel);
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
|
||||
const sanitized = stripThriftCommentsAndStrings(content);
|
||||
const namespace = extractNamespace(sanitized);
|
||||
namespacesByThrift.set(thriftPath, namespace);
|
||||
|
||||
for (const block of extractServiceBlocks(sanitized)) {
|
||||
const methods = extractMethods(block.body);
|
||||
const info: ThriftServiceInfo = {
|
||||
namespace,
|
||||
serviceName: block.name,
|
||||
methods,
|
||||
thriftPath,
|
||||
};
|
||||
const existing = servicesByName.get(block.name) ?? [];
|
||||
existing.push(info);
|
||||
servicesByName.set(block.name, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return { namespacesByThrift, servicesByName };
|
||||
}
|
||||
|
||||
export class ThriftExtractor implements ContractExtractor {
|
||||
type = 'thrift' as const;
|
||||
|
||||
async canExtract(_repo: RepoHandle): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async extract(
|
||||
_dbExecutor: CypherExecutor | null,
|
||||
repoPath: string,
|
||||
_repo: RepoHandle,
|
||||
): Promise<ExtractedContract[]> {
|
||||
const out: ExtractedContract[] = [];
|
||||
const context = await buildThriftContext(repoPath);
|
||||
|
||||
for (const infos of context.servicesByName.values()) {
|
||||
for (const info of infos) {
|
||||
for (const methodName of info.methods) {
|
||||
const symbolName = `${info.serviceName}.${methodName}`;
|
||||
out.push(
|
||||
makeContract(
|
||||
thriftMethodContractId(info.namespace, info.serviceName, methodName),
|
||||
'provider',
|
||||
info.thriftPath,
|
||||
symbolName,
|
||||
0.85,
|
||||
{
|
||||
namespace: info.namespace,
|
||||
service: info.serviceName,
|
||||
method: methodName,
|
||||
source: 'thrift_idl',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sourceFiles = await glob(THRIFT_SCAN_GLOB, {
|
||||
cwd: repoPath,
|
||||
absolute: false,
|
||||
nodir: true,
|
||||
ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'],
|
||||
});
|
||||
|
||||
const parser = new Parser();
|
||||
for (const rel of sourceFiles) {
|
||||
const plugin = getPluginForFile(rel);
|
||||
if (!plugin) continue;
|
||||
const content = readSafe(repoPath, rel);
|
||||
if (!content) continue;
|
||||
|
||||
let detections: ThriftDetection[] = [];
|
||||
try {
|
||||
parser.setLanguage(plugin.language);
|
||||
const tree = parser.parse(content);
|
||||
detections = plugin.scan(tree);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const detection of detections) {
|
||||
const contract = this.detectionToContract(detection, rel, context);
|
||||
if (contract) out.push(contract);
|
||||
}
|
||||
}
|
||||
|
||||
return this.dedupe(out);
|
||||
}
|
||||
|
||||
private detectionToContract(
|
||||
detection: ThriftDetection,
|
||||
filePath: string,
|
||||
context: ThriftContext,
|
||||
): ExtractedContract | null {
|
||||
const candidates = context.servicesByName.get(detection.serviceName) ?? [];
|
||||
if (candidates.length > 1) return null;
|
||||
|
||||
const info = candidates[0];
|
||||
if (info) {
|
||||
if (!info.methods.includes(detection.methodName)) return null;
|
||||
return makeContract(
|
||||
thriftMethodContractId(info.namespace, info.serviceName, detection.methodName),
|
||||
detection.role,
|
||||
filePath,
|
||||
detection.symbolName,
|
||||
detection.confidenceWithIdl,
|
||||
{
|
||||
namespace: info.namespace,
|
||||
service: info.serviceName,
|
||||
method: detection.methodName,
|
||||
source: detection.source,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
detection.role !== 'consumer' ||
|
||||
!detection.methodName ||
|
||||
!detection.usesGeneratedServiceMember
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return makeContract(
|
||||
thriftMethodContractId('', detection.serviceName, detection.methodName),
|
||||
detection.role,
|
||||
filePath,
|
||||
detection.symbolName,
|
||||
detection.confidenceWithoutIdl,
|
||||
{
|
||||
service: detection.serviceName,
|
||||
method: detection.methodName,
|
||||
source: 'java_thrift_consumer_weak',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private dedupe(items: ExtractedContract[]): ExtractedContract[] {
|
||||
const byKey = new Map<string, ExtractedContract>();
|
||||
for (const c of items) {
|
||||
const key = `${c.contractId}|${c.role}|${c.symbolRef.filePath}|${c.symbolName}`;
|
||||
const existing = byKey.get(key);
|
||||
if (!existing || c.confidence > existing.confidence) {
|
||||
byKey.set(key, c);
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as path from 'node:path';
|
||||
import type { ThriftLanguagePlugin } from './types.js';
|
||||
import { JAVA_THRIFT_PLUGIN } from './java.js';
|
||||
|
||||
export type { ThriftDetection, ThriftLanguagePlugin, ThriftRole } from './types.js';
|
||||
|
||||
const REGISTRY: Record<string, ThriftLanguagePlugin> = {
|
||||
'.java': JAVA_THRIFT_PLUGIN,
|
||||
};
|
||||
|
||||
export const THRIFT_SCAN_GLOB = '**/*.java';
|
||||
|
||||
export function getPluginForFile(rel: string): ThriftLanguagePlugin | undefined {
|
||||
const ext = path.extname(rel).toLowerCase();
|
||||
return REGISTRY[ext];
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import {
|
||||
compilePatterns,
|
||||
runCompiledPatterns,
|
||||
type LanguagePatterns,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import type { ThriftDetection, ThriftLanguagePlugin } from './types.js';
|
||||
|
||||
const GENERATED_MEMBER_TYPES = new Set(['Iface', 'Client']);
|
||||
const SERVICE_TYPE_RE = /^[A-Z][A-Za-z0-9]*(?:Service|Management)$/;
|
||||
|
||||
interface VariableBinding {
|
||||
name: string;
|
||||
serviceName: string;
|
||||
usesGeneratedServiceMember: boolean;
|
||||
scopeStart: number;
|
||||
scopeEnd: number;
|
||||
declarationEnd: number;
|
||||
scopeSize: number;
|
||||
}
|
||||
|
||||
interface ServiceTypeMatch {
|
||||
serviceName: string;
|
||||
usesGeneratedServiceMember: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_PATTERNS = compilePatterns({
|
||||
name: 'java-thrift-variables',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(field_declaration
|
||||
type: (_) @type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @var))
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(local_variable_declaration
|
||||
type: (_) @type
|
||||
declarator: (variable_declarator
|
||||
name: (identifier) @var))
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(formal_parameter
|
||||
type: (_) @type
|
||||
name: (identifier) @var)
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const CALL_PATTERNS = compilePatterns({
|
||||
name: 'java-thrift-method-calls',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_invocation
|
||||
object: (identifier) @receiver
|
||||
name: (identifier) @method)
|
||||
`,
|
||||
},
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(method_invocation
|
||||
object: (field_access
|
||||
object: (this)
|
||||
field: (identifier) @receiver)
|
||||
name: (identifier) @method)
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
const PROVIDER_PATTERNS = compilePatterns({
|
||||
name: 'java-thrift-providers',
|
||||
language: Java,
|
||||
patterns: [
|
||||
{
|
||||
meta: {},
|
||||
query: `
|
||||
(class_declaration
|
||||
name: (identifier) @class_name
|
||||
(super_interfaces
|
||||
(type_list
|
||||
(_) @type))
|
||||
body: (class_body) @body) @class
|
||||
`,
|
||||
},
|
||||
],
|
||||
} satisfies LanguagePatterns<Record<string, never>>);
|
||||
|
||||
function serviceFromType(typeText: string): ServiceTypeMatch | null {
|
||||
const segments = typeText.split('.').filter((segment) => segment.length > 0);
|
||||
const last = segments.at(-1);
|
||||
const service = segments.at(-2);
|
||||
if (last && service && GENERATED_MEMBER_TYPES.has(last)) {
|
||||
return { serviceName: service, usesGeneratedServiceMember: true };
|
||||
}
|
||||
return last && SERVICE_TYPE_RE.test(last)
|
||||
? { serviceName: last, usesGeneratedServiceMember: false }
|
||||
: null;
|
||||
}
|
||||
|
||||
function methodNamesInClassBody(body: Parser.SyntaxNode): string[] {
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < body.namedChildCount; i++) {
|
||||
const child = body.namedChild(i);
|
||||
if (!child || child.type !== 'method_declaration') continue;
|
||||
const name = child.childForFieldName('name');
|
||||
if (name?.text) names.push(name.text);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function nearestAncestor(node: Parser.SyntaxNode, types: Set<string>): Parser.SyntaxNode | null {
|
||||
let current: Parser.SyntaxNode | null = node;
|
||||
while (current) {
|
||||
if (types.has(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bindingScope(varNode: Parser.SyntaxNode): {
|
||||
scope: Parser.SyntaxNode;
|
||||
declarationEnd: number;
|
||||
} | null {
|
||||
const declaration = nearestAncestor(
|
||||
varNode,
|
||||
new Set(['field_declaration', 'local_variable_declaration', 'formal_parameter']),
|
||||
);
|
||||
if (!declaration) return null;
|
||||
|
||||
if (declaration.type === 'field_declaration') {
|
||||
const classBody = nearestAncestor(declaration, new Set(['class_body']));
|
||||
if (!classBody) return null;
|
||||
return { scope: classBody, declarationEnd: 0 };
|
||||
}
|
||||
|
||||
if (declaration.type === 'formal_parameter') {
|
||||
const callable = nearestAncestor(
|
||||
declaration,
|
||||
new Set(['method_declaration', 'constructor_declaration']),
|
||||
);
|
||||
if (!callable) return null;
|
||||
return { scope: callable, declarationEnd: 0 };
|
||||
}
|
||||
|
||||
const block = nearestAncestor(declaration, new Set(['block']));
|
||||
if (!block) return null;
|
||||
return { scope: block, declarationEnd: declaration.endIndex };
|
||||
}
|
||||
|
||||
function resolveServiceForReceiver(
|
||||
bindings: VariableBinding[],
|
||||
receiver: string,
|
||||
callNode: Parser.SyntaxNode,
|
||||
): VariableBinding | null {
|
||||
const callStart = callNode.startIndex;
|
||||
const candidates = bindings.filter(
|
||||
(binding) =>
|
||||
binding.name === receiver &&
|
||||
binding.scopeStart <= callStart &&
|
||||
callStart <= binding.scopeEnd &&
|
||||
binding.declarationEnd <= callStart,
|
||||
);
|
||||
candidates.sort((a, b) => {
|
||||
if (a.scopeSize !== b.scopeSize) return a.scopeSize - b.scopeSize;
|
||||
return b.declarationEnd - a.declarationEnd;
|
||||
});
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
export const JAVA_THRIFT_PLUGIN: ThriftLanguagePlugin = {
|
||||
name: 'java-thrift',
|
||||
language: Java,
|
||||
scan(tree) {
|
||||
const out: ThriftDetection[] = [];
|
||||
const bindings: VariableBinding[] = [];
|
||||
|
||||
for (const match of runCompiledPatterns(VARIABLE_PATTERNS, tree)) {
|
||||
const typeNode = match.captures.type;
|
||||
const varNode = match.captures.var;
|
||||
if (!typeNode || !varNode) continue;
|
||||
const service = serviceFromType(typeNode.text);
|
||||
if (!service) continue;
|
||||
const scope = bindingScope(varNode);
|
||||
if (!scope) continue;
|
||||
bindings.push({
|
||||
name: varNode.text,
|
||||
serviceName: service.serviceName,
|
||||
usesGeneratedServiceMember: service.usesGeneratedServiceMember,
|
||||
scopeStart: scope.scope.startIndex,
|
||||
scopeEnd: scope.scope.endIndex,
|
||||
declarationEnd: scope.declarationEnd,
|
||||
scopeSize: scope.scope.endIndex - scope.scope.startIndex,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of runCompiledPatterns(CALL_PATTERNS, tree)) {
|
||||
const receiver = match.captures.receiver?.text;
|
||||
const methodName = match.captures.method?.text;
|
||||
const callNode = match.captures.receiver?.parent;
|
||||
if (!receiver || !methodName) continue;
|
||||
if (!callNode) continue;
|
||||
const binding = resolveServiceForReceiver(bindings, receiver, callNode);
|
||||
if (!binding) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
serviceName: binding.serviceName,
|
||||
methodName,
|
||||
symbolName: `${receiver}.${methodName}`,
|
||||
source: 'java_thrift_consumer',
|
||||
confidenceWithIdl: 0.75,
|
||||
confidenceWithoutIdl: 0.45,
|
||||
usesGeneratedServiceMember: binding.usesGeneratedServiceMember,
|
||||
});
|
||||
}
|
||||
|
||||
const emittedProviders = new Set<string>();
|
||||
for (const match of runCompiledPatterns(PROVIDER_PATTERNS, tree)) {
|
||||
const typeNode = match.captures.type;
|
||||
const bodyNode = match.captures.body;
|
||||
if (!typeNode || !bodyNode) continue;
|
||||
const service = serviceFromType(typeNode.text);
|
||||
if (!service) continue;
|
||||
|
||||
for (const methodName of methodNamesInClassBody(bodyNode)) {
|
||||
const key = `${service.serviceName}.${methodName}`;
|
||||
if (emittedProviders.has(key)) continue;
|
||||
emittedProviders.add(key);
|
||||
out.push({
|
||||
role: 'provider',
|
||||
serviceName: service.serviceName,
|
||||
methodName,
|
||||
symbolName: `${service.serviceName}.${methodName}`,
|
||||
source: 'java_thrift_provider',
|
||||
confidenceWithIdl: 0.8,
|
||||
confidenceWithoutIdl: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type Parser from 'tree-sitter';
|
||||
|
||||
export type ThriftRole = 'provider' | 'consumer';
|
||||
|
||||
export interface ThriftDetection {
|
||||
role: ThriftRole;
|
||||
serviceName: string;
|
||||
methodName: string;
|
||||
symbolName: string;
|
||||
source: string;
|
||||
confidenceWithIdl: number;
|
||||
confidenceWithoutIdl: number;
|
||||
usesGeneratedServiceMember?: boolean;
|
||||
}
|
||||
|
||||
export interface ThriftLanguagePlugin {
|
||||
name: string;
|
||||
language: unknown;
|
||||
scan(tree: Parser.Tree): ThriftDetection[];
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export interface WildcardMatchResult {
|
||||
remaining: StoredContract[];
|
||||
}
|
||||
|
||||
function isGrpcWildcard(cid: string): boolean {
|
||||
return cid.startsWith('grpc::') && cid.endsWith('/*');
|
||||
function isServiceWildcard(cid: string): boolean {
|
||||
return (cid.startsWith('grpc::') || cid.startsWith('thrift::')) && cid.endsWith('/*');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,8 +69,9 @@ export function normalizeContractId(id: string): string {
|
||||
}
|
||||
return id;
|
||||
}
|
||||
case 'grpc': {
|
||||
// Canonical form: `grpc::<lowercased-package-or-service>[/<method>]`.
|
||||
case 'grpc':
|
||||
case 'thrift': {
|
||||
// Canonical form: `<type>::<lowercased-package-or-service>[/<method>]`.
|
||||
//
|
||||
// The package/service segment is lowercased because gRPC package
|
||||
// names are effectively case-insensitive across language bindings
|
||||
@@ -84,22 +85,23 @@ export function normalizeContractId(id: string): string {
|
||||
// as DISTINCT canonical forms: `grpc::userservice` does not match
|
||||
// `grpc::userservice/Login`. That's by design — callers that want
|
||||
// service-level manifest matching against method-level providers
|
||||
// should use the gRPC wildcard form `grpc::UserService/*` which is
|
||||
// should use the service wildcard form `grpc::UserService/*` or
|
||||
// `thrift::UserService/*` which is
|
||||
// handled by runWildcardMatch below.
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx > 0) {
|
||||
const pkg = rest.substring(0, slashIdx).toLowerCase();
|
||||
const method = rest.substring(slashIdx);
|
||||
return `grpc::${pkg}${method}`;
|
||||
return `${type}::${pkg}${method}`;
|
||||
}
|
||||
if (slashIdx === 0) {
|
||||
// Malformed "/method" with leading slash — keep as-is so two
|
||||
// equally malformed ids can still match each other.
|
||||
return `grpc::${rest}`;
|
||||
return `${type}::${rest}`;
|
||||
}
|
||||
// No slash: package/service only. Lowercase to match the package
|
||||
// segment produced by the pkg/method branch above.
|
||||
return `grpc::${rest.toLowerCase()}`;
|
||||
return `${type}::${rest.toLowerCase()}`;
|
||||
}
|
||||
case 'topic':
|
||||
return `topic::${rest.trim().toLowerCase()}`;
|
||||
@@ -125,6 +127,32 @@ function findMatchingKeys(contractId: string, index: Map<string, StoredContract[
|
||||
return matches;
|
||||
}
|
||||
|
||||
if (normalized.startsWith('thrift::')) {
|
||||
const rest = normalized.substring('thrift::'.length);
|
||||
const slashIdx = rest.indexOf('/');
|
||||
if (slashIdx > 0) {
|
||||
const service = rest.substring(0, slashIdx);
|
||||
const method = rest.substring(slashIdx + 1);
|
||||
if (!service.includes('.') && method && method !== '*') {
|
||||
const matches: string[] = [];
|
||||
for (const key of index.keys()) {
|
||||
if (!key.startsWith('thrift::') || key.endsWith('/*')) continue;
|
||||
const providerRest = key.substring('thrift::'.length);
|
||||
const providerSlashIdx = providerRest.indexOf('/');
|
||||
if (providerSlashIdx < 0) continue;
|
||||
const providerService = providerRest.substring(0, providerSlashIdx);
|
||||
const providerMethod = providerRest.substring(providerSlashIdx + 1);
|
||||
if (providerMethod !== method) continue;
|
||||
if (providerService === service || providerService.endsWith('.' + service)) {
|
||||
matches.push(key);
|
||||
}
|
||||
}
|
||||
matches.sort();
|
||||
return matches.length === 1 ? matches : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -152,8 +180,9 @@ export function runExactMatch(
|
||||
const isNoisy = buildNoisyContractFilter(matchingConfig);
|
||||
const index = providerIndex ?? buildProviderIndex(contracts, matchingConfig);
|
||||
|
||||
// Skip service wildcard consumers — they go to wildcard pass only
|
||||
const consumers = contracts.filter(
|
||||
(c) => c.role === 'consumer' && !isGrpcWildcard(c.contractId) && !isNoisy(c.contractId),
|
||||
(c) => c.role === 'consumer' && !isServiceWildcard(c.contractId) && !isNoisy(c.contractId),
|
||||
);
|
||||
|
||||
const matched: CrossLink[] = [];
|
||||
@@ -198,15 +227,15 @@ export function runExactMatch(
|
||||
|
||||
// normalUnmatched: contracts that weren't matched in exact pass
|
||||
const normalUnmatched = contracts.filter((c) => {
|
||||
if (isGrpcWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
if (isServiceWildcard(c.contractId)) return false; // excluded from exact, handled separately
|
||||
if (isNoisy(c.contractId)) return false; // excluded from matching — don't surface as unmatched
|
||||
const id = `${c.repo}::${c.contractId}`;
|
||||
return c.role === 'provider' ? !matchedProviderIds.has(id) : !matchedConsumerIds.has(id);
|
||||
});
|
||||
|
||||
// Re-add gRPC wildcard contracts — they were never in exact matching
|
||||
const grpcWildcards = contracts.filter((c) => isGrpcWildcard(c.contractId));
|
||||
const unmatched = [...normalUnmatched, ...grpcWildcards];
|
||||
// Re-add service wildcard contracts — they were never in exact matching
|
||||
const serviceWildcards = contracts.filter((c) => isServiceWildcard(c.contractId));
|
||||
const unmatched = [...normalUnmatched, ...serviceWildcards];
|
||||
|
||||
return { matched, unmatched };
|
||||
}
|
||||
@@ -216,21 +245,28 @@ export function runWildcardMatch(
|
||||
providerIndex: Map<string, StoredContract[]>,
|
||||
): WildcardMatchResult {
|
||||
const wildcardConsumers = unmatched.filter(
|
||||
(c) => c.role === 'consumer' && isGrpcWildcard(c.contractId),
|
||||
(c) => c.role === 'consumer' && isServiceWildcard(c.contractId),
|
||||
);
|
||||
const matched: CrossLink[] = [];
|
||||
const matchedConsumerIds = new Set<string>();
|
||||
|
||||
for (const consumer of wildcardConsumers) {
|
||||
const normalized = normalizeContractId(consumer.contractId);
|
||||
const typeEnd = normalized.indexOf('::');
|
||||
const consumerType = normalized.slice(0, typeEnd);
|
||||
// "grpc::com.example.userservice/*" → "com.example.userservice"
|
||||
// "grpc::userservice/*" → "userservice"
|
||||
const fqService = normalized.slice(normalized.indexOf('::') + 2, -2); // strip "grpc::" and "/*"
|
||||
// "thrift::userservice/*" → "userservice"
|
||||
const fqService = normalized.slice(typeEnd + 2, -2); // strip "<type>::" and "/*"
|
||||
const candidateProviders: StoredContract[] = [];
|
||||
const matchedProviderServices = new Set<string>();
|
||||
|
||||
for (const [key, providers] of providerIndex) {
|
||||
// Only match against non-wildcard gRPC providers (method-level IDs)
|
||||
if (!key.startsWith('grpc::') || key.endsWith('/*')) continue;
|
||||
const afterPrefix = key.slice(6); // strip "grpc::"
|
||||
// Only match against non-wildcard same-type providers (method-level IDs).
|
||||
const keyTypeEnd = key.indexOf('::');
|
||||
if (keyTypeEnd < 0 || key.endsWith('/*')) continue;
|
||||
const providerType = key.slice(0, keyTypeEnd);
|
||||
if (providerType !== consumerType) continue;
|
||||
const afterPrefix = key.slice(keyTypeEnd + 2); // strip "<type>::"
|
||||
const slashIdx = afterPrefix.indexOf('/');
|
||||
if (slashIdx < 0) continue;
|
||||
const providerFqService = afterPrefix.slice(0, slashIdx);
|
||||
@@ -242,39 +278,46 @@ export function runWildcardMatch(
|
||||
|
||||
if (!isMatch) continue;
|
||||
|
||||
for (const provider of providers) {
|
||||
// Skip same-repo same-service (same logic as runExactMatch)
|
||||
if (provider.repo === consumer.repo) {
|
||||
if (!provider.service || !consumer.service || provider.service === consumer.service) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
matchedProviderServices.add(providerFqService);
|
||||
candidateProviders.push(...providers);
|
||||
}
|
||||
|
||||
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, // consumer's wildcard ID
|
||||
matchType: 'wildcard',
|
||||
confidence: Math.min(provider.confidence, consumer.confidence),
|
||||
});
|
||||
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
|
||||
if (consumerType === 'thrift' && !fqService.includes('.') && matchedProviderServices.size > 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const provider of candidateProviders) {
|
||||
// Skip same-repo same-service (same logic as runExactMatch)
|
||||
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, // consumer's wildcard ID
|
||||
matchType: 'wildcard',
|
||||
confidence: Math.min(provider.confidence, consumer.confidence),
|
||||
});
|
||||
matchedConsumerIds.add(`${consumer.repo}::${consumer.contractId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = unmatched.filter((c) => {
|
||||
if (c.role !== 'consumer' || !isGrpcWildcard(c.contractId)) return true;
|
||||
if (c.role !== 'consumer' || !isServiceWildcard(c.contractId)) return true;
|
||||
return !matchedConsumerIds.has(`${c.repo}::${c.contractId}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { randomBytes } from 'node:crypto';
|
||||
import type { ContractRegistry } from './types.js';
|
||||
|
||||
const CONTRACTS_FILE = 'contracts.json';
|
||||
@@ -34,7 +35,7 @@ export async function writeContractRegistry(
|
||||
registry: ContractRegistry,
|
||||
): Promise<void> {
|
||||
const targetPath = path.join(groupDir, CONTRACTS_FILE);
|
||||
const tmpPath = `${targetPath}.tmp.${Date.now()}`;
|
||||
const tmpPath = `${targetPath}.tmp.${randomBytes(8).toString('hex')}`;
|
||||
|
||||
await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8');
|
||||
await fsp.rename(tmpPath, targetPath);
|
||||
|
||||
@@ -6,10 +6,11 @@ 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 { ThriftExtractor } from './extractors/thrift-extractor.js';
|
||||
import { TopicExtractor } from './extractors/topic-extractor.js';
|
||||
import { ManifestExtractor } from './extractors/manifest-extractor.js';
|
||||
import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js';
|
||||
import { runExactMatch } from './matching.js';
|
||||
import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js';
|
||||
import { detectServiceBoundaries, assignService } from './service-boundary-detector.js';
|
||||
import type { CypherExecutor } from './contract-extractor.js';
|
||||
import { writeContractRegistry } from './storage.js';
|
||||
@@ -96,6 +97,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries);
|
||||
const httpEx = new HttpRouteExtractor();
|
||||
const grpcEx = new GrpcExtractor();
|
||||
const thriftEx = new ThriftExtractor();
|
||||
const topicEx = new TopicExtractor();
|
||||
dbExecutors = new Map<string, CypherExecutor>();
|
||||
const openPoolIds: string[] = [];
|
||||
@@ -143,6 +145,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
if (config.detect.thrift) {
|
||||
const extracted = await thriftEx.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) {
|
||||
@@ -234,13 +247,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
const { matched, unmatched } = runExactMatch(autoContracts, undefined, config.matching);
|
||||
const providerIndex = buildProviderIndex(autoContracts, config.matching);
|
||||
const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching);
|
||||
const wildcard = runWildcardMatch(unmatched, providerIndex);
|
||||
|
||||
// Dedupe cross-links. Manifest contracts participate in runExactMatch, so a
|
||||
// manifest-declared link can also emit a matchType:'exact' CrossLink with the
|
||||
// same endpoints. Prefer the manifest version — it reflects operator intent
|
||||
// and carries matchType:'manifest' which downstream consumers may rely on.
|
||||
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]);
|
||||
const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]);
|
||||
const allContracts: StoredContract[] = autoContracts;
|
||||
|
||||
const registry: ContractRegistry = {
|
||||
@@ -259,7 +274,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
|
||||
return {
|
||||
contracts: allContracts,
|
||||
crossLinks,
|
||||
unmatched,
|
||||
unmatched: wildcard.remaining,
|
||||
missingRepos,
|
||||
repoSnapshots,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ContractType = 'http' | 'grpc' | 'topic' | 'lib' | 'custom';
|
||||
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom';
|
||||
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
|
||||
export type ContractRole = 'provider' | 'consumer';
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface GroupManifestLink {
|
||||
export interface DetectConfig {
|
||||
http: boolean;
|
||||
grpc: boolean;
|
||||
thrift: boolean;
|
||||
topics: boolean;
|
||||
shared_libs: boolean;
|
||||
embedding_fallback: boolean;
|
||||
|
||||
@@ -30,10 +30,10 @@ export function populateGoRangeBindings(
|
||||
|
||||
for (const rangeNode of tree.rootNode.descendantsOfType('for_statement')) {
|
||||
const rangeClause = rangeNode.namedChildren.find((c) => c.type === 'range_clause');
|
||||
if (rangeClause === null) continue;
|
||||
if (rangeClause === undefined) continue;
|
||||
|
||||
const left = rangeClause.namedChildren.find((c) => c.type === 'expression_list');
|
||||
if (left === null) continue;
|
||||
if (left === undefined) continue;
|
||||
|
||||
const rangeExpr = rangeClause.namedChildren.find(
|
||||
(c, idx) => c.type !== 'expression_list' && idx > rangeClause.namedChildren.indexOf(left),
|
||||
|
||||
@@ -50,7 +50,7 @@ export function synthesizeGoTypeBindings(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
const typeArg = args.namedChildren.find((c) =>
|
||||
['type_identifier', 'qualified_type'].includes(c.type),
|
||||
);
|
||||
if (typeArg !== null) {
|
||||
if (typeArg !== undefined) {
|
||||
const typeName = extractSimpleTypeNameText(typeArg);
|
||||
const nameNodes = lhs.namedChildren.filter((c) => c.type === 'identifier');
|
||||
if (nameNodes.length > 0) {
|
||||
@@ -71,13 +71,13 @@ export function synthesizeGoTypeBindings(rootNode: SyntaxNode): CaptureMatch[] {
|
||||
// V1: channel_type not handled — make(chan T) produces no typeBinding.
|
||||
['slice_type', 'map_type'].includes(c.type),
|
||||
);
|
||||
if (sliceOrMap !== null) {
|
||||
if (sliceOrMap !== undefined) {
|
||||
let typeName = '';
|
||||
if (sliceOrMap.type === 'slice_type') {
|
||||
const elem = sliceOrMap.namedChildren.find((c) =>
|
||||
['type_identifier', 'qualified_type'].includes(c.type),
|
||||
);
|
||||
if (elem !== null) typeName = extractSimpleTypeNameText(elem);
|
||||
if (elem !== undefined) typeName = extractSimpleTypeNameText(elem);
|
||||
} else if (sliceOrMap.type === 'map_type') {
|
||||
const typeChildren = sliceOrMap.namedChildren.filter((c) =>
|
||||
['type_identifier', 'qualified_type'].includes(c.type),
|
||||
|
||||
@@ -94,6 +94,31 @@ export function resolveWorkerPoolOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function waitForWorkerOnline(worker: Worker): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
worker.removeListener('online', onOnline);
|
||||
worker.removeListener('error', onError);
|
||||
worker.removeListener('exit', onExit);
|
||||
};
|
||||
const onOnline = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: Error) => {
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
const onExit = (code: number) => {
|
||||
cleanup();
|
||||
reject(new Error(`Replacement worker exited with code ${code} before coming online`));
|
||||
};
|
||||
worker.once('online', onOnline);
|
||||
worker.once('error', onError);
|
||||
worker.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function estimateItemBytes(item: unknown): number {
|
||||
if (typeof item !== 'object' || item === null) return 0;
|
||||
const content = (item as { content?: unknown }).content;
|
||||
@@ -209,7 +234,21 @@ export const createWorkerPool = (
|
||||
const replaceWorker = async (workerIndex: number) => {
|
||||
const worker = workers[workerIndex];
|
||||
await worker?.terminate().catch(() => undefined);
|
||||
if (!stopped) workers[workerIndex] = new Worker(workerUrl);
|
||||
if (stopped) return;
|
||||
const replacement = new Worker(workerUrl);
|
||||
try {
|
||||
await waitForWorkerOnline(replacement);
|
||||
} catch (err) {
|
||||
await replacement.terminate().catch(() => undefined);
|
||||
throw new Error(
|
||||
`Replacement worker ${workerIndex} failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
if (stopped) {
|
||||
await replacement.terminate().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
workers[workerIndex] = replacement;
|
||||
};
|
||||
|
||||
const fail = async (err: Error) => {
|
||||
@@ -341,9 +380,7 @@ export const createWorkerPool = (
|
||||
try {
|
||||
await replaceWorker(workerIndex);
|
||||
} catch (err) {
|
||||
void fail(
|
||||
err instanceof Error ? err : new Error(`Worker replacement failed: ${err}`),
|
||||
);
|
||||
void fail(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
} finally {
|
||||
activeWorkers--;
|
||||
|
||||
@@ -188,7 +188,7 @@ export class ExtensionManager {
|
||||
const policy = opts.policy ?? this.options.policy ?? resolvePolicyFromEnv();
|
||||
const timeoutMs =
|
||||
opts.installTimeoutMs ?? this.options.installTimeoutMs ?? getExtensionInstallTimeoutMs();
|
||||
const warn = this.options.warn ?? console.warn;
|
||||
const warn = this.options.warn ?? console.error;
|
||||
|
||||
if (policy === 'never') {
|
||||
this.markUnavailable(name, label, 'extension install policy is "never"', warn);
|
||||
|
||||
@@ -257,18 +257,7 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
|
||||
// Close stale connection inside the session lock to prevent race conditions
|
||||
// with concurrent operations that might acquire the lock between cleanup steps
|
||||
await runWithSessionLock(async () => {
|
||||
try {
|
||||
if (conn) await conn.close();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
try {
|
||||
if (db) await db.close();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
conn = null;
|
||||
db = null;
|
||||
await safeClose();
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
@@ -294,14 +283,7 @@ const ensureLbugInitialized = async (dbPath: string) => {
|
||||
const doInitLbug = async (dbPath: string) => {
|
||||
// Different database requested — close the old one first
|
||||
if (conn || db) {
|
||||
try {
|
||||
if (conn) await conn.close();
|
||||
} catch {}
|
||||
try {
|
||||
if (db) await db.close();
|
||||
} catch {}
|
||||
conn = null;
|
||||
db = null;
|
||||
await safeClose();
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
@@ -348,7 +330,7 @@ const doInitLbug = async (dbPath: string) => {
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (!msg.includes('already exists')) {
|
||||
console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
|
||||
console.error(`[gitnexus:lbug] schema creation warning: ${msg.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1028,15 +1010,15 @@ export const fetchExistingEmbeddingHashes = async (
|
||||
const nodeId = r.nodeId ?? r[0];
|
||||
if (nodeId) map.set(nodeId, STALE_HASH_SENTINEL);
|
||||
}
|
||||
console.log(
|
||||
`[embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`,
|
||||
console.error(
|
||||
`[gitnexus:embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`,
|
||||
);
|
||||
return map;
|
||||
} catch (fallbackErr: any) {
|
||||
const fallbackMsg = fallbackErr?.message ?? '';
|
||||
if (isMissingColumnOrTableError(fallbackMsg)) {
|
||||
console.log(
|
||||
`[embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`,
|
||||
console.error(
|
||||
`[gitnexus:embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
@@ -1047,19 +1029,62 @@ export const fetchExistingEmbeddingHashes = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const closeLbug = async (): Promise<void> => {
|
||||
/**
|
||||
* Flush the WAL so all pending writes are visible to subsequent readers.
|
||||
*
|
||||
* Best-effort: swallows errors from older LadybugDB versions or schemaless
|
||||
* databases that do not support the CHECKPOINT command. A no-op when there
|
||||
* is nothing pending, so safe (and cheap) to call unconditionally after any
|
||||
* write path.
|
||||
*
|
||||
* Use this instead of safeClose when the connection must stay open
|
||||
* (e.g. the /api/embed handler that keeps serving queries after flushing).
|
||||
*
|
||||
* @see safeClose — CHECKPOINT + connection/database close
|
||||
*/
|
||||
export const flushWAL = async (): Promise<void> => {
|
||||
if (!conn) return;
|
||||
try {
|
||||
await conn.query('CHECKPOINT');
|
||||
} catch {
|
||||
/* ignore — older LadybugDB or schemaless DB may not accept it */
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Flush the WAL and close the connection and database handles.
|
||||
*
|
||||
* Consolidates the CHECKPOINT + close pattern into a single function so
|
||||
* callers never call conn.close() or db.close() directly (#1376).
|
||||
* An ESLint no-restricted-syntax rule enforces this — see eslint.config.mjs.
|
||||
*
|
||||
* @see flushWAL — CHECKPOINT-only (connection stays open)
|
||||
* @see closeLbug — safeClose + module state reset (full teardown)
|
||||
*/
|
||||
export const safeClose = async (): Promise<void> => {
|
||||
await flushWAL();
|
||||
if (conn) {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax -- sole authorised close site
|
||||
await conn.close();
|
||||
} catch {}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
conn = null;
|
||||
}
|
||||
if (db) {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax -- sole authorised close site
|
||||
await db.close();
|
||||
} catch {}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
db = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const closeLbug = async (): Promise<void> => {
|
||||
await safeClose();
|
||||
currentDbPath = null;
|
||||
ftsLoaded = false;
|
||||
vectorExtensionLoaded = false;
|
||||
|
||||
@@ -84,9 +84,21 @@ 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);
|
||||
// Stdout-capture state lives in `gitnexus/src/mcp/stdio-capture.ts` — a leaf
|
||||
// module with zero non-`node:` imports. We re-export the same symbols here
|
||||
// so the existing test mock seam (`gitnexus/src/mcp/core/lbug-adapter.ts`
|
||||
// re-exports * from this file, and 8+ test files use that path with
|
||||
// `vi.mock(...)`) continues to work without churn. The source of truth is
|
||||
// the leaf module; this re-export is a compatibility shim.
|
||||
//
|
||||
// Why the leaf module exists: Codex's adversarial review on PR #1383 found
|
||||
// that putting this state in pool-adapter.ts pulled `@ladybugdb/core` into
|
||||
// `cli/mcp.ts`'s static-import closure (via stdio-context → pool-adapter →
|
||||
// @ladybugdb/core), corrupting stdout in the pre-sentinel window. Routing
|
||||
// through the leaf breaks that chain.
|
||||
export { realStdoutWrite, realStderrWrite, setActiveStdoutWrite } from '../../mcp/stdio-capture.js';
|
||||
import { getActiveStdoutWrite } from '../../mcp/stdio-capture.js';
|
||||
|
||||
let stdoutSilenceCount = 0;
|
||||
/** True while pre-warming connections — prevents watchdog from prematurely restoring stdout */
|
||||
let preWarmActive = false;
|
||||
@@ -209,6 +221,7 @@ let activeQueryCount = 0;
|
||||
*/
|
||||
export function silenceStdout(): void {
|
||||
if (stdoutSilenceCount++ === 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax -- silencing infrastructure; replacement is a no-op
|
||||
process.stdout.write = (() => true) as any;
|
||||
}
|
||||
}
|
||||
@@ -216,7 +229,8 @@ export function silenceStdout(): void {
|
||||
export function restoreStdout(): void {
|
||||
if (--stdoutSilenceCount <= 0) {
|
||||
stdoutSilenceCount = 0;
|
||||
process.stdout.write = realStdoutWrite;
|
||||
// eslint-disable-next-line no-restricted-syntax -- restoring the active stdout-write handler is the silencing API contract
|
||||
process.stdout.write = getActiveStdoutWrite();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +241,8 @@ export function restoreStdout(): void {
|
||||
setInterval(() => {
|
||||
if (stdoutSilenceCount > 0 && !preWarmActive && activeQueryCount === 0) {
|
||||
stdoutSilenceCount = 0;
|
||||
process.stdout.write = realStdoutWrite;
|
||||
// eslint-disable-next-line no-restricted-syntax -- watchdog recovery for stuck silencing
|
||||
process.stdout.write = getActiveStdoutWrite();
|
||||
}
|
||||
}, 1000).unref();
|
||||
|
||||
|
||||
@@ -60,6 +60,13 @@ export interface AnalyzeOptions {
|
||||
*/
|
||||
force?: boolean;
|
||||
embeddings?: boolean;
|
||||
/**
|
||||
* Override the auto-skip node-count cap for embedding generation.
|
||||
* `undefined` (default) keeps the built-in 50,000-node safety limit;
|
||||
* `0` disables the cap entirely; any positive integer sets a custom cap.
|
||||
* Mapped from the CLI's `--embeddings [limit]` argument.
|
||||
*/
|
||||
embeddingsNodeLimit?: number;
|
||||
/**
|
||||
* Explicitly drop any embeddings present in the existing index instead of
|
||||
* preserving them. Only meaningful when `embeddings` is false/undefined:
|
||||
@@ -107,14 +114,15 @@ export interface AnalyzeResult {
|
||||
pipelineResult?: any;
|
||||
}
|
||||
|
||||
/** Threshold: auto-skip embeddings for repos with more nodes than this */
|
||||
const EMBEDDING_NODE_LIMIT = 50_000;
|
||||
|
||||
// Re-export the pure flag-derivation helper so external callers (and tests)
|
||||
// keep importing from this module's stable surface.
|
||||
export { deriveEmbeddingMode } from './embedding-mode.js';
|
||||
export { deriveEmbeddingMode, DEFAULT_EMBEDDING_NODE_LIMIT } from './embedding-mode.js';
|
||||
export type { EmbeddingMode } from './embedding-mode.js';
|
||||
import { deriveEmbeddingMode as _deriveEmbeddingMode } from './embedding-mode.js';
|
||||
import {
|
||||
deriveEmbeddingMode as _deriveEmbeddingMode,
|
||||
deriveEmbeddingCap,
|
||||
DEFAULT_EMBEDDING_NODE_LIMIT,
|
||||
} from './embedding-mode.js';
|
||||
|
||||
export const PHASE_LABELS: Record<string, string> = {
|
||||
extracting: 'Scanning files',
|
||||
@@ -333,8 +341,27 @@ export async function runFullAnalysis(
|
||||
let semanticMode: 'vector-index' | 'exact-scan' | undefined;
|
||||
|
||||
if (shouldGenerateEmbeddings) {
|
||||
if (stats.nodes <= EMBEDDING_NODE_LIMIT) {
|
||||
const { skipForCap, capDisabled, nodeLimit } = deriveEmbeddingCap(
|
||||
stats.nodes,
|
||||
options.embeddingsNodeLimit,
|
||||
);
|
||||
if (!skipForCap) {
|
||||
embeddingSkipped = false;
|
||||
if (capDisabled && stats.nodes > DEFAULT_EMBEDDING_NODE_LIMIT) {
|
||||
log(
|
||||
`Embedding node-count cap disabled — generating embeddings for ` +
|
||||
`${stats.nodes.toLocaleString()} nodes. Ensure sufficient memory; ` +
|
||||
`the default ${DEFAULT_EMBEDDING_NODE_LIMIT.toLocaleString()}-node ` +
|
||||
`cap exists to prevent OOM.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
`Embeddings skipped: ${stats.nodes.toLocaleString()} nodes exceeds ` +
|
||||
`the ${nodeLimit.toLocaleString()}-node safety cap. ` +
|
||||
`Override with \`--embeddings 0\` to disable the cap, or ` +
|
||||
`\`--embeddings <n>\` to set a custom cap.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -175,8 +175,10 @@ const logFailure = (key: string, result: LoadResult): void => {
|
||||
logged.add(key);
|
||||
const message = `[gitnexus] ${result.note} (${result.error.message})`;
|
||||
|
||||
if (result.severity === 'error') console.error(message);
|
||||
else console.warn(message);
|
||||
// Both severities go to stderr — console.warn writes to stderr too, but
|
||||
// console.error is the stdout-safe channel we standardize on across
|
||||
// MCP-reachable code so the ESLint rule covers this directory.
|
||||
console.error(message);
|
||||
};
|
||||
|
||||
export const resolveLanguageKey = (language: SupportedLanguages, filePath?: string): string =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
TransportSendOptions,
|
||||
} from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { JSONRPCMessageSchema, type JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { withMcpWrite } from './stdio-context.js';
|
||||
|
||||
export type StdioFraming = 'content-length' | 'newline';
|
||||
|
||||
@@ -232,7 +233,12 @@ export class CompatibleStdioServerTransport implements Transport {
|
||||
|
||||
this._stdout.on('error', onError);
|
||||
|
||||
if (this._stdout.write(payload)) {
|
||||
// Tag the write with the MCP transport context so the sentinel
|
||||
// (server.ts createStdoutSentinel Proxy) recognizes it as a legitimate
|
||||
// JSON-RPC frame and passes it through to the real stdout instead of
|
||||
// redirecting to stderr.
|
||||
const writeOk = withMcpWrite(() => this._stdout.write(payload));
|
||||
if (writeOk) {
|
||||
this._stdout.removeListener('error', onError);
|
||||
resolve();
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/**
|
||||
* LadybugDB connection pool — re-exported from core.
|
||||
* Prefer importing from `../../core/lbug/pool-adapter.js` in new code.
|
||||
*
|
||||
* KEEP THIS FILE. It is intentionally a shim re-export of
|
||||
* `../../core/lbug/pool-adapter.js`. The MCP test suite uses this path as
|
||||
* a vi.mock seam so unit tests can stub LadybugDB without affecting other
|
||||
* importers of `core/lbug/pool-adapter.js` (which is shared with the
|
||||
* analyze pipeline). New non-test code MAY import from `pool-adapter.js`
|
||||
* directly, but the shim must continue to exist for the mock seam to work.
|
||||
*/
|
||||
export * from '../../core/lbug/pool-adapter.js';
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
GetPromptRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { GITNEXUS_TOOLS } from './tools.js';
|
||||
import { realStdoutWrite } from './core/lbug-adapter.js';
|
||||
import { installGlobalStdoutSentinel } from './stdio-context.js';
|
||||
import type { LocalBackend } from './local/local-backend.js';
|
||||
import { getResourceDefinitions, getResourceTemplates, readResource } from './resources.js';
|
||||
|
||||
@@ -287,19 +287,31 @@ Follow these steps:
|
||||
export async function startMCPServer(backend: LocalBackend): Promise<void> {
|
||||
const server = createMCPServer(backend);
|
||||
|
||||
// Use the shared stdout reference captured at module-load time by the
|
||||
// lbug-adapter. Avoids divergence if anything patches stdout between
|
||||
// module load and server start.
|
||||
const _safeStdout = new Proxy(process.stdout, {
|
||||
// Idempotent global sentinel install. cli/mcp.ts calls this first thing
|
||||
// (before warnMissingOptionalGrammars / backend.init can emit to stdout);
|
||||
// calling again here is a safety net for direct callers of startMCPServer
|
||||
// (tests, future entry points). The transport's _safeStdout Proxy is a
|
||||
// second layer that guarantees transport writes reach the sentinel even
|
||||
// if anything else re-replaces process.stdout.write later. Tagged
|
||||
// transport writes (wrapped in withMcpWrite by compatible-stdio-transport.send)
|
||||
// pass through to the captured realStdoutWrite; untagged writes reaching
|
||||
// the Proxy or process.stdout get redirected to stderr with the
|
||||
// [mcp:stdout-redirect] prefix. See stdio-context.ts.
|
||||
const sentinel = installGlobalStdoutSentinel();
|
||||
const safeStdout = new Proxy(process.stdout, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'write') return realStdoutWrite;
|
||||
if (prop === 'write') return sentinel.write;
|
||||
const val = Reflect.get(target, prop, receiver);
|
||||
return typeof val === 'function' ? val.bind(target) : val;
|
||||
},
|
||||
});
|
||||
const transport = new CompatibleStdioServerTransport(process.stdin, _safeStdout);
|
||||
const transport = new CompatibleStdioServerTransport(process.stdin, safeStdout);
|
||||
await server.connect(transport);
|
||||
|
||||
// Surface the redirect counter on shutdown so users see the volume of
|
||||
// stray writes even when individual payloads were truncated/suppressed.
|
||||
process.on('exit', () => sentinel.flushSummary());
|
||||
|
||||
// Graceful shutdown helper
|
||||
let shuttingDown = false;
|
||||
const shutdown = async (exitCode = 0) => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Stdio capture — leaf module with zero non-`node:` imports.
|
||||
*
|
||||
* Owns the singleton state that the MCP stdout sentinel needs:
|
||||
* - `realStdoutWrite` / `realStderrWrite`: process.stdout.write /
|
||||
* process.stderr.write captured at module load, BEFORE anything else
|
||||
* can rebind them.
|
||||
* - `activeStdoutWrite`: the write handler that silenceStdout/restoreStdout
|
||||
* cycles in pool-adapter restore to. Defaults to `realStdoutWrite`;
|
||||
* `installGlobalStdoutSentinel` (in stdio-context.ts) registers the
|
||||
* sentinel here at MCP startup so silence/restore preserves the sentinel.
|
||||
*
|
||||
* This module exists separately from `pool-adapter.ts` (which previously
|
||||
* owned the same state) so that `cli/mcp.ts`'s static-import closure does
|
||||
* NOT transitively pull in `@ladybugdb/core`. Codex's adversarial review on
|
||||
* PR #1383 found that the prior structure left a pre-sentinel window where
|
||||
* native-module init banners could reach raw stdout: `cli/mcp.ts` →
|
||||
* `mcp/stdio-context.ts` → `core/lbug/pool-adapter.ts` → `@ladybugdb/core`.
|
||||
* Routing the sentinel state through this leaf module breaks that chain.
|
||||
*
|
||||
* **Constraint:** keep this module a leaf. No non-`node:` imports — adding
|
||||
* any would re-introduce the import-time stdout-corruption hazard.
|
||||
*/
|
||||
|
||||
type StdoutWrite = typeof process.stdout.write;
|
||||
|
||||
/** Captured at module load, before any rebinding. */
|
||||
// eslint-disable-next-line no-restricted-syntax -- this IS the captured-real-write infrastructure used by the MCP sentinel
|
||||
export const realStdoutWrite: StdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
export const realStderrWrite: typeof process.stderr.write = process.stderr.write.bind(
|
||||
process.stderr,
|
||||
);
|
||||
|
||||
/**
|
||||
* The function `restoreStdout` (and the watchdog) in pool-adapter restore
|
||||
* *to* when un-silencing. Defaults to the captured real write; the MCP
|
||||
* server registers its sentinel here at startMCPServer (via
|
||||
* installGlobalStdoutSentinel) so silenceStdout cycles preserve the sentinel
|
||||
* instead of unwinding to raw stdout.
|
||||
*/
|
||||
let activeStdoutWrite: StdoutWrite = realStdoutWrite;
|
||||
|
||||
/**
|
||||
* Register a wrapper (e.g., the MCP sentinel) as the active stdout write.
|
||||
* silenceStdout/restoreStdout cycles in pool-adapter will preserve the
|
||||
* wrapper instead of unwinding to the raw realStdoutWrite. Returns the
|
||||
* previous value so callers can chain or restore.
|
||||
*/
|
||||
export function setActiveStdoutWrite(fn: StdoutWrite): StdoutWrite {
|
||||
const prev = activeStdoutWrite;
|
||||
activeStdoutWrite = fn;
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the currently-active stdout write handler. Used by pool-adapter's
|
||||
* restoreStdout and watchdog so silence/restore preserves the sentinel.
|
||||
*/
|
||||
export function getActiveStdoutWrite(): StdoutWrite {
|
||||
return activeStdoutWrite;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* MCP Stdio Context — AsyncLocalStorage-tagged transport-write detection.
|
||||
*
|
||||
* The MCP stdio transport writes JSON-RPC frames to stdout. Per spec, the
|
||||
* server MUST NOT write anything to stdout that is not a valid MCP message.
|
||||
* Stray writes from dependency code corrupt the protocol and present to
|
||||
* clients as a hung handshake or `MCP error -32000`.
|
||||
*
|
||||
* This module provides:
|
||||
* - withMcpWrite(fn): runs fn inside an AsyncLocalStorage context tagged
|
||||
* `mcp: true`. The transport wraps every send() in this so its writes
|
||||
* are recognizable as legitimate.
|
||||
* - isMcpWrite(): true when called inside withMcpWrite.
|
||||
* - createStdoutSentinel({...}): a write function suitable for installing
|
||||
* in a Proxy over process.stdout. Tagged writes pass through to the real
|
||||
* stdout; untagged writes are redirected to stderr with a [mcp:stdout-redirect]
|
||||
* prefix, truncated to maxBytes per redirect, and rate-limited to maxRedirects
|
||||
* per process so a stray loop cannot flood client logs.
|
||||
*
|
||||
* The sentinel is correctness-by-construction: it identifies legitimate
|
||||
* writes by *who* called write(), not by inspecting the bytes. A byte-shape
|
||||
* heuristic ("starts with {, ends with \n") would falsely reject Content-Length
|
||||
* frames (which start with C and end with }) and misclassify multi-chunk writes.
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
// Import from the leaf module, NOT `core/lbug/pool-adapter.js`. pool-adapter
|
||||
// pulls in `@ladybugdb/core`, which would put the native module in
|
||||
// `cli/mcp.ts`'s static-import closure — exactly the pre-sentinel window
|
||||
// Codex's adversarial review flagged on PR #1383.
|
||||
import { realStdoutWrite, realStderrWrite, setActiveStdoutWrite } from './stdio-capture.js';
|
||||
|
||||
interface McpWriteContext {
|
||||
mcp: true;
|
||||
}
|
||||
|
||||
const store = new AsyncLocalStorage<McpWriteContext>();
|
||||
|
||||
export function withMcpWrite<T>(fn: () => T): T {
|
||||
return store.run({ mcp: true }, fn);
|
||||
}
|
||||
|
||||
export function isMcpWrite(): boolean {
|
||||
return store.getStore()?.mcp === true;
|
||||
}
|
||||
|
||||
type WriteFn = typeof process.stdout.write;
|
||||
|
||||
export interface SentinelOptions {
|
||||
realStdoutWrite: WriteFn;
|
||||
realStderrWrite: WriteFn;
|
||||
/** Maximum bytes of payload to surface per redirect. Defaults to 200. */
|
||||
maxBytes?: number;
|
||||
/** Maximum number of redirects per process before suppression. Defaults to 10. */
|
||||
maxRedirects?: number;
|
||||
}
|
||||
|
||||
export interface SentinelStats {
|
||||
redirected: number;
|
||||
suppressed: number;
|
||||
}
|
||||
|
||||
export interface Sentinel {
|
||||
write: WriteFn;
|
||||
stats: () => SentinelStats;
|
||||
flushSummary: () => void;
|
||||
}
|
||||
|
||||
const REDIRECT_PREFIX = '[mcp:stdout-redirect] ';
|
||||
const STARTUP_WARNING =
|
||||
'[mcp:stdout-redirect] sentinel triggered — stray write redirected to stderr; subsequent redirects logged at exit\n';
|
||||
|
||||
function chunkToBuffer(chunk: any): Buffer {
|
||||
if (chunk === undefined || chunk === null) return Buffer.alloc(0);
|
||||
if (Buffer.isBuffer(chunk)) return chunk;
|
||||
if (typeof chunk === 'string') return Buffer.from(chunk, 'utf8');
|
||||
// Plain Uint8Array (e.g. from a TypedArray-using producer): copy bytes
|
||||
// verbatim instead of falling through to String(chunk), which produces
|
||||
// garbage like "1,2,3,...".
|
||||
if (chunk instanceof Uint8Array) return Buffer.from(chunk);
|
||||
return Buffer.from(String(chunk), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Node Writable.write contract: the completion callback, when present, is
|
||||
* always the last argument. Match exactly that — don't try to peer past
|
||||
* earlier arguments — so future overload shapes (e.g. an options object)
|
||||
* do not silently break callback delivery.
|
||||
*/
|
||||
function extractCallback(rest: unknown[]): ((err?: Error | null) => void) | undefined {
|
||||
const last = rest[rest.length - 1];
|
||||
return typeof last === 'function' ? (last as (err?: Error | null) => void) : undefined;
|
||||
}
|
||||
|
||||
export function createStdoutSentinel(opts: SentinelOptions): Sentinel {
|
||||
const maxBytes = opts.maxBytes ?? 200;
|
||||
const maxRedirects = opts.maxRedirects ?? 10;
|
||||
let redirected = 0;
|
||||
let suppressed = 0;
|
||||
let warningEmitted = false;
|
||||
|
||||
const stderr = (s: string | Buffer) => opts.realStderrWrite(s);
|
||||
|
||||
const write: WriteFn = (chunk: any, ...rest: any[]): boolean => {
|
||||
if (isMcpWrite()) {
|
||||
return opts.realStdoutWrite(chunk, ...rest);
|
||||
}
|
||||
|
||||
if (!warningEmitted) {
|
||||
warningEmitted = true;
|
||||
stderr(STARTUP_WARNING);
|
||||
}
|
||||
|
||||
if (redirected < maxRedirects) {
|
||||
redirected += 1;
|
||||
const buf = chunkToBuffer(chunk);
|
||||
const truncated = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf;
|
||||
|
||||
stderr(REDIRECT_PREFIX);
|
||||
if (truncated.length > 0) stderr(truncated);
|
||||
if (buf.length > maxBytes) {
|
||||
stderr(` (+${buf.length - maxBytes} bytes truncated)`);
|
||||
}
|
||||
if (truncated.length === 0 || truncated[truncated.length - 1] !== 0x0a) {
|
||||
stderr('\n');
|
||||
}
|
||||
} else {
|
||||
suppressed += 1;
|
||||
}
|
||||
|
||||
// Honor the Writable.write callback contract — fire async to match
|
||||
// Node's "next-tick" semantics so callers never observe sync reentry.
|
||||
const cb = extractCallback(rest);
|
||||
if (cb) {
|
||||
process.nextTick(() => cb(null));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return {
|
||||
write,
|
||||
stats: () => ({ redirected, suppressed }),
|
||||
flushSummary: () => {
|
||||
if (redirected === 0 && suppressed === 0) return;
|
||||
stderr(
|
||||
`[mcp:stdout-redirect] summary: ${redirected} redirected, ${suppressed} suppressed beyond cap\n`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the sentinel as the global stdout interceptor — idempotent.
|
||||
*
|
||||
* Does three things in order:
|
||||
* 1. Creates the sentinel from the captured `realStdoutWrite` / `realStderrWrite`.
|
||||
* 2. Replaces `process.stdout.write` with `sentinel.write`.
|
||||
* 3. Registers `sentinel.write` as the "active" handler in pool-adapter
|
||||
* so silenceStdout/restoreStdout cycles preserve the sentinel
|
||||
* instead of unwinding to raw stdout.
|
||||
*
|
||||
* Idempotent — callers may invoke it multiple times safely (cli/mcp.ts at
|
||||
* the top of mcpCommand, and startMCPServer). The earliest caller wins;
|
||||
* subsequent calls return the same sentinel handle. Call this BEFORE any
|
||||
* other startup work that might emit to stdout: native module loads,
|
||||
* `_require()`-style grammar detection, repo registry reads, embedder
|
||||
* pipeline initialization. Anything written before the sentinel is in
|
||||
* place reaches raw stdout uncaught.
|
||||
*
|
||||
* Returns the sentinel handle so the earliest caller can register
|
||||
* `process.on('exit', sentinel.flushSummary)`.
|
||||
*/
|
||||
let _installedSentinel: Sentinel | null = null;
|
||||
|
||||
export function installGlobalStdoutSentinel(): Sentinel {
|
||||
if (_installedSentinel) return _installedSentinel;
|
||||
const sentinel = createStdoutSentinel({ realStdoutWrite, realStderrWrite });
|
||||
// eslint-disable-next-line no-restricted-syntax -- installing the global sentinel is the API contract
|
||||
process.stdout.write = sentinel.write;
|
||||
setActiveStdoutWrite(sentinel.write);
|
||||
_installedSentinel = sentinel;
|
||||
return sentinel;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
executePrepared,
|
||||
executeWithReusedStatement,
|
||||
streamQuery,
|
||||
flushWAL,
|
||||
closeLbug,
|
||||
withLbugDb,
|
||||
} from '../core/lbug/lbug-adapter.js';
|
||||
@@ -183,6 +184,7 @@ a.ext:hover{text-decoration:underline}
|
||||
<div class="section-title">Endpoints</div>
|
||||
<p class="endpoint"><a href="/api/info">/api/info</a> <span style="color:#5a5a70">— Server version & context</span></p>
|
||||
<p class="endpoint"><a href="/api/repos">/api/repos</a> <span style="color:#5a5a70">— Indexed repositories</span></p>
|
||||
<p class="endpoint"><code>/api/health</code> <span style="color:#5a5a70">— Docker/orchestrator healthcheck</span></p>
|
||||
<p class="endpoint"><code>/api/heartbeat</code> <span style="color:#5a5a70">— SSE heartbeat</span></p>
|
||||
<p class="endpoint"><code>/api/graph</code> <code>/api/query</code> <code>/api/search</code> <span style="color:#5a5a70">— Data</span></p>
|
||||
<p class="endpoint"><code>/api/mcp</code> <span style="color:#5a5a70">— MCP over StreamableHTTP</span></p>
|
||||
@@ -777,6 +779,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
return found;
|
||||
};
|
||||
|
||||
// Lightweight healthcheck for Docker/orchestrator probes (#1147).
|
||||
// Returns immediately so container managers do not confuse a long-lived
|
||||
// SSE stream with an unhealthy server.
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// SSE heartbeat — clients connect to detect server liveness instantly.
|
||||
// When the server shuts down, the TCP connection drops and the client's
|
||||
// EventSource fires onerror immediately (no polling delay).
|
||||
@@ -1694,6 +1703,12 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
||||
undefined, // context
|
||||
existingEmbeddings,
|
||||
);
|
||||
|
||||
// Flush WAL so subsequent /api/search requests see the new
|
||||
// embeddings immediately (#1149). In the CLI path closeLbug()
|
||||
// handles this during process exit, but the server keeps the
|
||||
// connection open for other routes — a CHECKPOINT is enough.
|
||||
await flushWAL();
|
||||
});
|
||||
|
||||
clearTimeout(embedTimeout);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import rateLimit, { type RateLimitRequestHandler } from 'express-rate-limit';
|
||||
import rateLimit, { type RateLimitRequestHandler, ipKeyGenerator } from 'express-rate-limit';
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
@@ -138,6 +138,9 @@ export interface RouteLimiterOverrides {
|
||||
* - keyGenerator: req.ip with a socket.remoteAddress fallback so abruptly
|
||||
* closed connections do not trigger ERR_ERL_UNDEFINED_IP_ADDRESS
|
||||
* (which would 500 the request via Express's default error handler).
|
||||
* The IP is passed through `ipKeyGenerator` so IPv6 addresses are
|
||||
* normalised to their /56 subnet — without this, each IPv6 address
|
||||
* gets its own counter and the limit is trivially bypassed (#1360).
|
||||
* Caller must wire `app.set('trust proxy', ...)` correctly — see
|
||||
* createServer in api.ts.
|
||||
*
|
||||
@@ -151,7 +154,10 @@ export function createRouteLimiter(opts?: RouteLimiterOverrides): RateLimitReque
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
passOnStoreError: true,
|
||||
keyGenerator: (req: Request) => req.ip ?? req.socket?.remoteAddress ?? 'unknown',
|
||||
keyGenerator: (req: Request) => {
|
||||
const ip = req.ip ?? req.socket?.remoteAddress;
|
||||
return ip ? ipKeyGenerator(ip) : 'unknown';
|
||||
},
|
||||
message: { error: 'Too many requests, please try again later.' },
|
||||
...opts,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,17 @@ export const isGitRepo = (repoPath: string): boolean => {
|
||||
|
||||
export const getCurrentCommit = (repoPath: string): string => {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD', { cwd: repoPath }).toString().trim();
|
||||
return execSync('git rev-parse HEAD', {
|
||||
cwd: repoPath,
|
||||
// Suppress stderr -- without an explicit stdio option, Node's execSync
|
||||
// forwards the child's stderr to the parent process (documented behaviour).
|
||||
// When repoPath is not inside a git worktree, git prints
|
||||
// "fatal: not a git repository" to stderr, which leaks to the user's
|
||||
// terminal even though the error is caught here (#1172).
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -86,7 +96,13 @@ export const getRemoteUrl = (repoPath: string): string | undefined => {
|
||||
*/
|
||||
export const getGitRoot = (fromPath: string): string | null => {
|
||||
try {
|
||||
const raw = execSync('git rev-parse --show-toplevel', { cwd: fromPath }).toString().trim();
|
||||
const raw = execSync('git rev-parse --show-toplevel', {
|
||||
cwd: fromPath,
|
||||
// Suppress stderr -- see getCurrentCommit comment and #1172.
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
// On Windows, git returns /d/Projects/Foo — path.resolve normalizes to D:\Projects\Foo
|
||||
return path.resolve(raw);
|
||||
} catch {
|
||||
|
||||
@@ -115,6 +115,12 @@ export function withTestLbugDB(
|
||||
}
|
||||
}
|
||||
|
||||
// 5b. Flush WAL so seed data + FTS indexes are visible to the pool
|
||||
// adapter's read path. Without this, Windows CI intermittently
|
||||
// fails FTS queries because the WAL hasn't been checkpointed
|
||||
// before the pool adapter starts reading.
|
||||
await adapter.flushWAL();
|
||||
|
||||
// 6. Open pool adapter by injecting the core adapter's writable Database.
|
||||
// LadybugDB enforces file locks — writable + read-only can't coexist
|
||||
// on the same path, and db.close() segfaults on macOS due to N-API
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* MCP CLI static-import-closure regression test.
|
||||
*
|
||||
* Codex's adversarial review on PR #1383 found that even though `cli/mcp.ts`
|
||||
* is loaded lazily by Commander, ITS static imports (`startMCPServer`,
|
||||
* `LocalBackend`, `installGlobalStdoutSentinel`, `warnMissingOptionalGrammars`)
|
||||
* evaluate synchronously when the module loads — well before `mcpCommand`'s
|
||||
* function body runs. Three of those four imports transitively pull in
|
||||
* `core/lbug/pool-adapter.ts`, which `import`s `@ladybugdb/core` at module top
|
||||
* level. The native binding's init can write to raw stdout in that pre-sentinel
|
||||
* window and corrupt the JSON-RPC frame stream.
|
||||
*
|
||||
* This test locks in the fix: spawn a child Node process, import the built
|
||||
* `dist/cli/mcp.js` (without invoking `mcpCommand`), and assert that
|
||||
* `@ladybugdb/core` is NOT in the loaded-module set. The assertion is
|
||||
* evidence-based — it checks Node's CJS module cache, which is global per
|
||||
* process and tracks every native/CJS module loaded by either ESM or CJS
|
||||
* importers.
|
||||
*
|
||||
* Characterization-first: this test was written before the fix landed and
|
||||
* MUST fail against the pre-fix code. Run against the parent of the U1
|
||||
* commit to verify the regression signal works.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const DIST_MCP = path.join(REPO_ROOT, 'dist', 'cli', 'mcp.js');
|
||||
const DIST_MCP_URL = pathToFileURL(DIST_MCP).href;
|
||||
|
||||
const PROBE = `
|
||||
import { createRequire } from 'node:module';
|
||||
const req = createRequire(import.meta.url);
|
||||
const before = new Set(Object.keys(req.cache));
|
||||
await import(process.env.PROBE_TARGET);
|
||||
const after = new Set(Object.keys(req.cache));
|
||||
const newlyLoaded = [...after].filter((k) => !before.has(k));
|
||||
process.stdout.write(JSON.stringify(newlyLoaded));
|
||||
`;
|
||||
|
||||
describe('MCP CLI static-import closure', () => {
|
||||
it('does not load @ladybugdb/core when cli/mcp.js is imported (without invoking mcpCommand)', () => {
|
||||
if (!fs.existsSync(DIST_MCP)) {
|
||||
throw new Error(
|
||||
`dist/cli/mcp.js missing — run \`npm run build\` first (or \`npm run test:integration\` which builds via pretest:integration).`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', PROBE], {
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, PROBE_TARGET: DIST_MCP_URL, NODE_OPTIONS: '' },
|
||||
timeout: 30_000,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`probe failed (status ${result.status}):\nstderr:\n${result.stderr}\nstdout:\n${result.stdout}`,
|
||||
);
|
||||
}
|
||||
|
||||
const newlyLoaded = JSON.parse(result.stdout) as string[];
|
||||
|
||||
// The headline assertion: @ladybugdb/core (a native CJS module) must not
|
||||
// be loaded by the static-import closure of cli/mcp.js. If it is, the
|
||||
// pre-sentinel stdout window the prior fix tried to close is still open.
|
||||
const ladybugLoaded = newlyLoaded.filter((p) => /@ladybugdb[\\/]core/.test(p));
|
||||
expect(
|
||||
ladybugLoaded,
|
||||
`@ladybugdb/core was loaded at cli/mcp.js static-import time. ` +
|
||||
`mcpCommand cannot install the stdout sentinel before native init runs. ` +
|
||||
`Offending paths:\n${ladybugLoaded.join('\n')}\n\n` +
|
||||
`Full newly-loaded set (${newlyLoaded.length} entries):\n${newlyLoaded.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not load any tree-sitter native binding (sanity check on grammar imports)', () => {
|
||||
if (!fs.existsSync(DIST_MCP)) {
|
||||
throw new Error(`dist/cli/mcp.js missing — run \`npm run build\` first.`);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '-e', PROBE], {
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, PROBE_TARGET: DIST_MCP_URL, NODE_OPTIONS: '' },
|
||||
timeout: 30_000,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`probe failed: ${result.stderr}`);
|
||||
}
|
||||
|
||||
const newlyLoaded = JSON.parse(result.stdout) as string[];
|
||||
// No tree-sitter parser should load at cli/mcp.js static-import time.
|
||||
// The analyze path is the only caller of warnMissingOptionalGrammars
|
||||
// (which require()s each grammar); cli/mcp.ts itself does not invoke
|
||||
// it, and its static-import closure is leaf-only — so importing
|
||||
// dist/cli/mcp.js without invoking mcpCommand must not trigger any
|
||||
// native grammar binding load.
|
||||
const treeSitterNative = newlyLoaded.filter((p) => /tree-sitter-[a-z]+[\\/]build/.test(p));
|
||||
expect(
|
||||
treeSitterNative,
|
||||
`tree-sitter native bindings loaded at cli/mcp.js static-import time:\n${treeSitterNative.join('\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* MCP server end-to-end startup test.
|
||||
*
|
||||
* Spawns `node dist/cli/index.js mcp` as a child process, drives the MCP
|
||||
* stdio handshake (initialize → initialized → tools/list), and asserts:
|
||||
*
|
||||
* - The first JSON-RPC frame arrives within a CI-friendly time budget.
|
||||
* - Every byte the server writes to stdout reassembles into a valid
|
||||
* Content-Length-framed JSON-RPC message — any stray byte fails the
|
||||
* test and is surfaced in the assertion message.
|
||||
* - tools/list reports the GitNexus tool set we expect.
|
||||
*
|
||||
* This locks in U1 (no stray console.log/warn in MCP-reachable code) and
|
||||
* U3 (AsyncLocalStorage stdout sentinel). A regression in either would
|
||||
* present as either a non-frame byte on stdout (fail-fast) or a missing
|
||||
* frame (timeout).
|
||||
*
|
||||
* Requires the built dist/. Use `npm run test:integration` (which runs
|
||||
* `npm run build` via the pretest:integration hook) or run after
|
||||
* `npm run build`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||
const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js');
|
||||
|
||||
const FIRST_FRAME_BUDGET_MS = process.env.CI ? 15_000 : 5_000;
|
||||
const TOTAL_BUDGET_MS = process.env.CI ? 30_000 : 10_000;
|
||||
|
||||
interface SpawnedServer {
|
||||
proc: ChildProcessWithoutNullStreams;
|
||||
stdoutChunks: Buffer[];
|
||||
stderrChunks: Buffer[];
|
||||
/** Resolves with the next JSON-RPC message parsed from stdout. */
|
||||
nextMessage: () => Promise<unknown>;
|
||||
/** Bytes received on stdout that did NOT belong to a frame body. */
|
||||
strayStdoutBytes: () => Buffer;
|
||||
send: (message: unknown) => void;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the built MCP server and provide a frame-aware reader.
|
||||
* The reader strictly parses Content-Length framing; any byte outside
|
||||
* a valid header→body window is captured as "stray" so the test can
|
||||
* assert the stream is clean.
|
||||
*/
|
||||
function spawnMcpServer(): SpawnedServer {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
// Avoid adding indexed repos noise to the test.
|
||||
GITNEXUS_HOME: path.join(REPO_ROOT, 'test', 'integration', 'mcp', '.tmp-home'),
|
||||
// Be deterministic across machines.
|
||||
NODE_OPTIONS: '',
|
||||
};
|
||||
|
||||
const proc = spawn(process.execPath, [DIST_CLI, 'mcp'], {
|
||||
cwd: REPO_ROOT,
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
const stray: Buffer[] = [];
|
||||
const messageQueue: unknown[] = [];
|
||||
const waiters: Array<(msg: unknown) => void> = [];
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
// Parser state machine for Content-Length framing.
|
||||
// 0 = expecting header, 1 = reading body of length `expected`.
|
||||
let state: 0 | 1 = 0;
|
||||
let expected = 0;
|
||||
|
||||
const HEADER_END = Buffer.from('\r\n\r\n', 'utf8');
|
||||
|
||||
function pushMessage(msg: unknown) {
|
||||
if (waiters.length > 0) {
|
||||
const w = waiters.shift()!;
|
||||
w(msg);
|
||||
} else {
|
||||
messageQueue.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function tryParse() {
|
||||
while (true) {
|
||||
if (state === 0) {
|
||||
const hdrEnd = buffer.indexOf(HEADER_END);
|
||||
if (hdrEnd === -1) return;
|
||||
const header = buffer.subarray(0, hdrEnd).toString('utf8');
|
||||
// Anything before the Content-Length: line (e.g. random bytes) is stray.
|
||||
// Strict: the header MUST start with "Content-Length:" (case-insensitive).
|
||||
const m = /^Content-Length:\s*(\d+)\s*$/im.exec(header);
|
||||
if (!m) {
|
||||
stray.push(buffer.subarray(0, hdrEnd + HEADER_END.length));
|
||||
buffer = buffer.subarray(hdrEnd + HEADER_END.length);
|
||||
continue;
|
||||
}
|
||||
// If there's text between buffer start and the header line, it's stray
|
||||
// unless the entire header parsed cleanly with no preamble.
|
||||
const headerStart = header.search(/Content-Length:/i);
|
||||
if (headerStart > 0) {
|
||||
stray.push(buffer.subarray(0, headerStart));
|
||||
buffer = buffer.subarray(headerStart);
|
||||
continue;
|
||||
}
|
||||
expected = parseInt(m[1], 10);
|
||||
buffer = buffer.subarray(hdrEnd + HEADER_END.length);
|
||||
state = 1;
|
||||
}
|
||||
if (state === 1) {
|
||||
if (buffer.length < expected) return;
|
||||
const bodyBuf = buffer.subarray(0, expected);
|
||||
buffer = buffer.subarray(expected);
|
||||
state = 0;
|
||||
try {
|
||||
pushMessage(JSON.parse(bodyBuf.toString('utf8')));
|
||||
} catch (err) {
|
||||
// Body that doesn't parse as JSON is a fatal protocol error.
|
||||
stray.push(bodyBuf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutChunks.push(chunk);
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
tryParse();
|
||||
});
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrChunks.push(chunk);
|
||||
});
|
||||
|
||||
return {
|
||||
proc,
|
||||
stdoutChunks,
|
||||
stderrChunks,
|
||||
nextMessage: () =>
|
||||
new Promise<unknown>((resolve, reject) => {
|
||||
if (messageQueue.length > 0) {
|
||||
resolve(messageQueue.shift());
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for JSON-RPC message. stderr so far:\n${Buffer.concat(stderrChunks).toString('utf8')}`,
|
||||
),
|
||||
);
|
||||
}, TOTAL_BUDGET_MS);
|
||||
waiters.push((msg) => {
|
||||
clearTimeout(timer);
|
||||
resolve(msg);
|
||||
});
|
||||
}),
|
||||
strayStdoutBytes: () => {
|
||||
// Include any leftover unparsed buffer.
|
||||
const tail = state === 0 ? buffer : Buffer.alloc(0);
|
||||
return Buffer.concat([...stray, tail]);
|
||||
},
|
||||
send: (message: unknown) => {
|
||||
const body = JSON.stringify(message);
|
||||
const frame = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
|
||||
proc.stdin.write(frame);
|
||||
},
|
||||
close: async () => {
|
||||
proc.stdin.end();
|
||||
// Give the server a moment to clean up; force-kill if it hangs.
|
||||
await new Promise<void>((resolve) => {
|
||||
const killer = setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
resolve();
|
||||
}, 2000);
|
||||
proc.on('close', () => {
|
||||
clearTimeout(killer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('MCP server end-to-end startup', () => {
|
||||
it('preserves JSON-RPC stdout discipline through initialize + tools/list', async () => {
|
||||
if (!fs.existsSync(DIST_CLI)) {
|
||||
throw new Error(
|
||||
`dist/cli/index.js missing — run \`npm run build\` first (or use \`npm run test:integration\` which builds via pretest:integration).`,
|
||||
);
|
||||
}
|
||||
|
||||
const server = spawnMcpServer();
|
||||
try {
|
||||
// initialize handshake
|
||||
const startedAt = Date.now();
|
||||
server.send({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'gitnexus-startup-test', version: '0.0.0' },
|
||||
},
|
||||
});
|
||||
|
||||
const initResponse = (await server.nextMessage()) as {
|
||||
jsonrpc: string;
|
||||
id: number;
|
||||
result?: { protocolVersion: string; serverInfo: { name: string } };
|
||||
error?: unknown;
|
||||
};
|
||||
const firstFrameAt = Date.now();
|
||||
|
||||
expect(initResponse.jsonrpc).toBe('2.0');
|
||||
expect(initResponse.id).toBe(1);
|
||||
expect(initResponse.error).toBeUndefined();
|
||||
expect(initResponse.result).toBeDefined();
|
||||
expect(initResponse.result!.serverInfo.name).toMatch(/gitnexus/i);
|
||||
expect(firstFrameAt - startedAt).toBeLessThan(FIRST_FRAME_BUDGET_MS);
|
||||
|
||||
// initialized notification (no response expected)
|
||||
server.send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
||||
|
||||
// tools/list
|
||||
server.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
||||
const toolsResponse = (await server.nextMessage()) as {
|
||||
jsonrpc: string;
|
||||
id: number;
|
||||
result?: { tools: Array<{ name: string }> };
|
||||
};
|
||||
|
||||
expect(toolsResponse.id).toBe(2);
|
||||
expect(toolsResponse.result).toBeDefined();
|
||||
const toolNames = (toolsResponse.result!.tools ?? []).map((t) => t.name);
|
||||
// The published GitNexus tool set. Adjust if the surface changes.
|
||||
const expectedTools = [
|
||||
'list_repos',
|
||||
'query',
|
||||
'context',
|
||||
'impact',
|
||||
'detect_changes',
|
||||
'rename',
|
||||
];
|
||||
for (const t of expectedTools) {
|
||||
expect(toolNames).toContain(t);
|
||||
}
|
||||
|
||||
// The headline assertion: every byte the server emitted on stdout
|
||||
// must reassemble into a valid JSON-RPC frame. Any leftover is a
|
||||
// protocol-corruption regression.
|
||||
const stray = server.strayStdoutBytes();
|
||||
if (stray.length > 0) {
|
||||
const stderr = Buffer.concat(server.stderrChunks).toString('utf8');
|
||||
throw new Error(
|
||||
`Stdout contained ${stray.length} bytes outside JSON-RPC framing — protocol corruption regression.\nStray bytes (utf8): ${JSON.stringify(stray.toString('utf8'))}\nStderr from server:\n${stderr}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -52,6 +52,21 @@ describe('setupCommand skills integration', () => {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports the OpenCode skills install path with the plural skills directory', async () => {
|
||||
await fs.mkdir(path.join(tempHome, '.config', 'opencode'), { recursive: true });
|
||||
await setupCommand();
|
||||
|
||||
const installedSkill = await fs.readFile(
|
||||
path.join(tempHome, '.config', 'opencode', 'skills', 'gitnexus-cli', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(installedSkill).toContain('GitNexus CLI Commands');
|
||||
await expect(
|
||||
fs.access(path.join(tempHome, '.config', 'opencode', 'skill', 'gitnexus-cli', 'SKILL.md')),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('installs packaged, flat-file, and directory skills into cursor skills directory', async () => {
|
||||
await setupCommand();
|
||||
|
||||
@@ -96,7 +111,7 @@ describe('setupCommand skills integration', () => {
|
||||
|
||||
const codexConfig = await fs.readFile(path.join(tempHome, '.codex', 'config.toml'), 'utf-8');
|
||||
expect(codexConfig).toContain('[mcp_servers.gitnexus]');
|
||||
expect(codexConfig).toContain('gitnexus@latest');
|
||||
expect(codexConfig).toMatch(/gitnexus@\d+\.\d+\.\d+/);
|
||||
|
||||
const codexSkill = await fs.readFile(
|
||||
path.join(tempHome, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'),
|
||||
|
||||
@@ -300,7 +300,7 @@ describe('worker pool integration', () => {
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, {
|
||||
subBatchIdleTimeoutMs: 150,
|
||||
subBatchIdleTimeoutMs: 500,
|
||||
maxTimeoutRetries: 1,
|
||||
timeoutBackoffFactor: 4,
|
||||
});
|
||||
@@ -308,7 +308,46 @@ describe('worker pool integration', () => {
|
||||
try {
|
||||
const results = await pool.dispatch<any, any>([{ path: 'retry.ts', content: '' }]);
|
||||
expect(results).toEqual([{ fileCount: 1, recovered: true }]);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Retrying with 0.6s timeout'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Retrying with 2s timeout'));
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects dispatch when replacement worker crashes during startup', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-replace-fail-'));
|
||||
const markerPath = path.join(tempDir, 'first-attempt.txt');
|
||||
const workerPath = path.join(tempDir, 'worker.js');
|
||||
fs.writeFileSync(
|
||||
workerPath,
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
const { parentPort } = require('node:worker_threads');
|
||||
const markerPath = ${JSON.stringify(markerPath)};
|
||||
if (fs.existsSync(markerPath)) {
|
||||
throw new Error('simulated startup crash');
|
||||
}
|
||||
parentPort.on('message', (msg) => {
|
||||
if (msg && msg.type === 'sub-batch') {
|
||||
fs.writeFileSync(markerPath, 'stalled');
|
||||
return;
|
||||
}
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, {
|
||||
subBatchIdleTimeoutMs: 150,
|
||||
maxTimeoutRetries: 1,
|
||||
timeoutBackoffFactor: 4,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(pool.dispatch<any, any>([{ path: 'crash.ts', content: '' }])).rejects.toThrow(
|
||||
/simulated startup crash|exited with code/,
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const runFullAnalysisMock = vi.fn();
|
||||
|
||||
vi.mock('../../src/core/run-analyze.js', () => ({
|
||||
runFullAnalysis: runFullAnalysisMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
closeLbug: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })),
|
||||
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
|
||||
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
|
||||
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
|
||||
assertAnalysisFinalized: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
getGitRoot: vi.fn(() => '/repo'),
|
||||
hasGitDir: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
|
||||
getMaxFileSizeBannerMessage: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
describe('analyzeCommand --embeddings [limit] parsing', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runFullAnalysisMock.mockReset();
|
||||
runFullAnalysisMock.mockResolvedValue({
|
||||
repoName: 'repo',
|
||||
repoPath: '/repo',
|
||||
stats: {},
|
||||
alreadyUpToDate: true,
|
||||
});
|
||||
process.exitCode = undefined;
|
||||
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
|
||||
});
|
||||
|
||||
it.each(['abc', '-1', '1.5', 'NaN', 'Infinity'])(
|
||||
'rejects invalid --embeddings value %s before analysis starts',
|
||||
async (embeddings) => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { embeddings });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
||||
const msg = errorSpy.mock.calls[0]?.[0] ?? '';
|
||||
expect(msg).toContain('--embeddings expects a non-negative integer');
|
||||
expect(msg).toContain(`got "${embeddings}"`);
|
||||
errorSpy.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
it('bare --embeddings forwards undefined limit (default cap honored downstream)', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { embeddings: true });
|
||||
|
||||
expect(runFullAnalysisMock).toHaveBeenCalledTimes(1);
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.embeddings).toBe(true);
|
||||
expect(opts.embeddingsNodeLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('--embeddings 0 forwards 0 (cap disabled downstream)', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { embeddings: '0' });
|
||||
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.embeddings).toBe(true);
|
||||
expect(opts.embeddingsNodeLimit).toBe(0);
|
||||
});
|
||||
|
||||
it('--embeddings <n> forwards a positive custom cap', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, { embeddings: '100000' });
|
||||
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.embeddings).toBe(true);
|
||||
expect(opts.embeddingsNodeLimit).toBe(100_000);
|
||||
});
|
||||
|
||||
it('omitted --embeddings keeps embeddings off (boolean false, no limit)', async () => {
|
||||
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
||||
|
||||
await analyzeCommand(undefined, {});
|
||||
|
||||
const opts = runFullAnalysisMock.mock.calls[0][1];
|
||||
expect(opts.embeddings).toBe(false);
|
||||
expect(opts.embeddingsNodeLimit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* Tests isGitRepo, getCurrentCommit, getGitRoot, and the newly added
|
||||
* hasGitDir helper introduced for issue #384 (indexing non-git folders).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
@@ -97,6 +97,26 @@ describe('getCurrentCommit', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: #1172 — without explicit stdio on execSync, Node forwards
|
||||
// the child's stderr to the parent process, printing "fatal: not a git
|
||||
// repository" to the user's terminal even though the error is caught.
|
||||
it('does not leak git stderr to process.stderr (#1172)', async () => {
|
||||
const { getCurrentCommit } = await import('../../src/storage/git.js');
|
||||
// git-init a dir without commits so `git rev-parse HEAD` fails with a
|
||||
// "fatal:" message — the exact class of error that leaked before the fix.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
|
||||
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
||||
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
try {
|
||||
expect(getCurrentCommit(tmpDir)).toBe('');
|
||||
const stderrOutput = spy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(stderrOutput).not.toContain('fatal');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getGitRoot ───────────────────────────────────────────────────────────
|
||||
@@ -111,6 +131,21 @@ describe('getGitRoot', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: #1172 -- mirrors the getCurrentCommit stderr test above.
|
||||
it('does not leak git stderr to process.stderr (#1172)', async () => {
|
||||
const { getGitRoot } = await import('../../src/storage/git.js');
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-'));
|
||||
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
try {
|
||||
getGitRoot(tmpDir);
|
||||
const stderrOutput = spy.mock.calls.map((c) => String(c[0])).join('');
|
||||
expect(stderrOutput).not.toContain('fatal');
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getRemoteUrl ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +64,36 @@ repos:
|
||||
expect(config.matching.exclude_links_param_only_paths).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults thrift detection to true', () => {
|
||||
const minimal = `
|
||||
version: 1
|
||||
name: test
|
||||
repos:
|
||||
app: my-app
|
||||
`;
|
||||
const config = parseGroupConfig(minimal);
|
||||
expect(config.detect.thrift).toBe(true);
|
||||
});
|
||||
|
||||
it('parses thrift manifest links', () => {
|
||||
const yaml = `
|
||||
version: 1
|
||||
name: test
|
||||
repos:
|
||||
gateway: gateway-repo
|
||||
orders: orders-repo
|
||||
links:
|
||||
- from: gateway
|
||||
to: orders
|
||||
type: thrift
|
||||
contract: billing.v1.OrderService/PlaceOrder
|
||||
role: consumer
|
||||
`;
|
||||
const config = parseGroupConfig(yaml);
|
||||
expect(config.links[0].type).toBe('thrift');
|
||||
expect(config.links[0].contract).toBe('billing.v1.OrderService/PlaceOrder');
|
||||
});
|
||||
|
||||
it('throws on missing required fields', () => {
|
||||
expect(() => parseGroupConfig('version: 1')).toThrow(/name.*required/i);
|
||||
expect(() => parseGroupConfig('name: test')).toThrow(/version.*required/i);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Security tests for insecure tempfile remediation (#1318 U6).
|
||||
*
|
||||
* CodeQL js/insecure-temporary-file flags predictable temp filenames
|
||||
* (e.g. Date.now() suffix) because an attacker with write access to
|
||||
* the same directory can win a symlink race. The fix replaces all
|
||||
* predictable suffixes with crypto.randomBytes(8).
|
||||
*
|
||||
* Two layers:
|
||||
* 1. Structural — source-grep confirms randomBytes, not Date.now().
|
||||
* 2. Behavioural — writeContractRegistry produces no leftover tmp files
|
||||
* and the final file is correctly written.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { writeContractRegistry, readContractRegistry } from '../../../src/core/group/storage.js';
|
||||
import { writeBridgeMeta, readBridgeMeta } from '../../../src/core/group/bridge-db.js';
|
||||
import type { ContractRegistry, BridgeMeta } from '../../../src/core/group/types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural: source files use randomBytes, not Date.now(), for temp paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('insecure tempfile — structural guards (#1318 U6)', () => {
|
||||
let bridgeSource: string;
|
||||
let storageSource: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
bridgeSource = await fsp.readFile(
|
||||
path.join(__dirname, '..', '..', '..', 'src', 'core', 'group', 'bridge-db.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
storageSource = await fsp.readFile(
|
||||
path.join(__dirname, '..', '..', '..', 'src', 'core', 'group', 'storage.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('bridge-db.ts imports randomBytes from node:crypto', () => {
|
||||
expect(bridgeSource).toMatch(/import\s*\{[^}]*randomBytes[^}]*\}\s*from\s*'node:crypto'/);
|
||||
});
|
||||
|
||||
it('bridge-db.ts uses randomBytes for bridge.lbug temp path', () => {
|
||||
expect(bridgeSource).toMatch(/bridge\.lbug\.tmp\.\$\{randomBytes/);
|
||||
});
|
||||
|
||||
it('bridge-db.ts uses randomBytes for meta.json temp path', () => {
|
||||
expect(bridgeSource).toMatch(/\.tmp\.\$\{randomBytes\(8\)\.toString\('hex'\)\}/);
|
||||
});
|
||||
|
||||
it('bridge-db.ts does not use Date.now() in any temp path', () => {
|
||||
// Match Date.now() specifically in tmp-path contexts — not in unrelated code.
|
||||
const tmpDateNow = bridgeSource.match(/\.tmp\.\$\{Date\.now\(\)\}/g) ?? [];
|
||||
expect(tmpDateNow.length).toBe(0);
|
||||
});
|
||||
|
||||
it('bridge-db.ts uses readdir-based cleanup for stale bridge tmp files', () => {
|
||||
expect(bridgeSource).toMatch(/cleanStaleBridgeTmpFiles/);
|
||||
expect(bridgeSource).toMatch(/readdir\(groupDir\)/);
|
||||
expect(bridgeSource).toMatch(/startsWith\('bridge\.lbug\.tmp\.'\)/);
|
||||
});
|
||||
|
||||
it('bridge-db.ts calls cleanStaleBridgeTmpFiles before openBridgeDb in writeBridge', () => {
|
||||
// Ensure cleanup happens before the DB is opened with the new random path.
|
||||
const cleanIdx = bridgeSource.indexOf('cleanStaleBridgeTmpFiles(groupDir)');
|
||||
const openIdx = bridgeSource.indexOf('openBridgeDb(tmpPath)');
|
||||
expect(cleanIdx).toBeGreaterThan(-1);
|
||||
expect(openIdx).toBeGreaterThan(-1);
|
||||
expect(cleanIdx).toBeLessThan(openIdx);
|
||||
});
|
||||
|
||||
it('storage.ts imports randomBytes from node:crypto', () => {
|
||||
expect(storageSource).toMatch(/import\s*\{[^}]*randomBytes[^}]*\}\s*from\s*'node:crypto'/);
|
||||
});
|
||||
|
||||
it('storage.ts uses randomBytes for contracts.json temp path', () => {
|
||||
expect(storageSource).toMatch(/\.tmp\.\$\{randomBytes\(8\)\.toString\('hex'\)\}/);
|
||||
});
|
||||
|
||||
it('storage.ts does not use Date.now() in any temp path', () => {
|
||||
const tmpDateNow = storageSource.match(/\.tmp\.\$\{Date\.now\(\)\}/g) ?? [];
|
||||
expect(tmpDateNow.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavioural: writeContractRegistry atomic write leaves no tmp files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('insecure tempfile — behavioural (#1318 U6)', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-u6-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sampleRegistry: ContractRegistry = {
|
||||
version: 1,
|
||||
generatedAt: '2026-05-06T00:00:00Z',
|
||||
repoSnapshots: {},
|
||||
missingRepos: [],
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
};
|
||||
|
||||
it('writeContractRegistry leaves no .tmp files after completion', async () => {
|
||||
await writeContractRegistry(tmpDir, sampleRegistry);
|
||||
|
||||
const files = await fsp.readdir(tmpDir);
|
||||
const tmpFiles = files.filter((f) => f.includes('.tmp.'));
|
||||
expect(tmpFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('writeContractRegistry writes correct data to final path', async () => {
|
||||
await writeContractRegistry(tmpDir, sampleRegistry);
|
||||
|
||||
const loaded = await readContractRegistry(tmpDir);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded!.version).toBe(1);
|
||||
expect(loaded!.generatedAt).toBe('2026-05-06T00:00:00Z');
|
||||
});
|
||||
|
||||
it('concurrent writes do not collide (randomBytes prevents same-ms race)', async () => {
|
||||
// Fire two writes simultaneously — with Date.now() these could collide
|
||||
// if they land in the same millisecond. With randomBytes they can't.
|
||||
await Promise.all([
|
||||
writeContractRegistry(tmpDir, { ...sampleRegistry, generatedAt: 'A' }),
|
||||
writeContractRegistry(tmpDir, { ...sampleRegistry, generatedAt: 'B' }),
|
||||
]);
|
||||
|
||||
const loaded = await readContractRegistry(tmpDir);
|
||||
expect(loaded).not.toBeNull();
|
||||
// One of the two writes wins the rename — we just verify no crash.
|
||||
expect(['A', 'B']).toContain(loaded!.generatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavioural: writeBridgeMeta atomic write leaves no tmp files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('insecure tempfile — writeBridgeMeta behavioural (#1318 U6)', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-u6-meta-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sampleMeta: BridgeMeta = {
|
||||
version: 1,
|
||||
generatedAt: '2026-05-06T00:00:00Z',
|
||||
missingRepos: ['repo-x'],
|
||||
};
|
||||
|
||||
it('writeBridgeMeta leaves no .tmp files after completion', async () => {
|
||||
await writeBridgeMeta(tmpDir, sampleMeta);
|
||||
|
||||
const files = await fsp.readdir(tmpDir);
|
||||
const tmpFiles = files.filter((f) => f.includes('.tmp.'));
|
||||
expect(tmpFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('writeBridgeMeta writes correct data to meta.json', async () => {
|
||||
await writeBridgeMeta(tmpDir, sampleMeta);
|
||||
|
||||
const loaded = await readBridgeMeta(tmpDir);
|
||||
expect(loaded.version).toBe(1);
|
||||
expect(loaded.generatedAt).toBe('2026-05-06T00:00:00Z');
|
||||
expect(loaded.missingRepos).toEqual(['repo-x']);
|
||||
});
|
||||
|
||||
it('concurrent writeBridgeMeta calls do not collide', async () => {
|
||||
await Promise.all([
|
||||
writeBridgeMeta(tmpDir, { ...sampleMeta, generatedAt: 'A' }),
|
||||
writeBridgeMeta(tmpDir, { ...sampleMeta, generatedAt: 'B' }),
|
||||
]);
|
||||
|
||||
const loaded = await readBridgeMeta(tmpDir);
|
||||
expect(['A', 'B']).toContain(loaded.generatedAt);
|
||||
});
|
||||
});
|
||||
@@ -169,6 +169,90 @@ describe('ManifestExtractor', () => {
|
||||
expect(provider?.symbolUid).toBe('uid-correct-login');
|
||||
});
|
||||
|
||||
it('resolves grpc package-qualified service-only manifest by full service name', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'platform/orders',
|
||||
to: 'platform/auth',
|
||||
type: 'grpc',
|
||||
contract: 'auth.AuthService',
|
||||
role: 'consumer',
|
||||
},
|
||||
];
|
||||
|
||||
let seenServiceName: string | undefined;
|
||||
const dbExecutors = new Map<
|
||||
string,
|
||||
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
|
||||
>([
|
||||
[
|
||||
'platform/auth',
|
||||
async (_cypher, params) => {
|
||||
seenServiceName = params?.serviceName as string;
|
||||
if (params?.serviceName === 'auth.AuthService') {
|
||||
return [
|
||||
{
|
||||
uid: 'uid-auth-service',
|
||||
name: 'auth.AuthService',
|
||||
filePath: 'src/auth.proto',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
],
|
||||
['platform/orders', async () => []],
|
||||
]);
|
||||
|
||||
const result = await extractor.extractFromManifest(links, dbExecutors);
|
||||
|
||||
expect(seenServiceName).toBe('auth.AuthService');
|
||||
const provider = result.contracts.find((c) => c.role === 'provider');
|
||||
expect(provider?.symbolUid).toBe('uid-auth-service');
|
||||
});
|
||||
|
||||
it('resolves thrift package-qualified service-only manifest by simple service name', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
from: 'gateway',
|
||||
to: 'orders',
|
||||
type: 'thrift',
|
||||
contract: 'billing.v1.OrderService',
|
||||
role: 'consumer',
|
||||
},
|
||||
];
|
||||
|
||||
let seenServiceName: string | undefined;
|
||||
const dbExecutors = new Map<
|
||||
string,
|
||||
(cypher: string, params?: Record<string, unknown>) => Promise<Record<string, unknown>[]>
|
||||
>([
|
||||
[
|
||||
'orders',
|
||||
async (_cypher, params) => {
|
||||
seenServiceName = params?.serviceName as string;
|
||||
if (params?.serviceName === 'OrderService') {
|
||||
return [
|
||||
{
|
||||
uid: 'uid-order-service',
|
||||
name: 'OrderService',
|
||||
filePath: 'idl/order.thrift',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
],
|
||||
['gateway', async () => []],
|
||||
]);
|
||||
|
||||
const result = await extractor.extractFromManifest(links, dbExecutors);
|
||||
|
||||
expect(seenServiceName).toBe('OrderService');
|
||||
const provider = result.contracts.find((c) => c.role === 'provider');
|
||||
expect(provider?.symbolUid).toBe('uid-order-service');
|
||||
});
|
||||
|
||||
it('resolves lib manifest links by exact name only', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
@@ -578,6 +662,33 @@ describe('ManifestExtractor', () => {
|
||||
expect(lowerContractId).toBe(upperContractId);
|
||||
});
|
||||
|
||||
it('builds thrift manifest contracts with synthetic uids when unresolved', async () => {
|
||||
const extractor = new ManifestExtractor();
|
||||
const result = await extractor.extractFromManifest([
|
||||
{
|
||||
from: 'gateway',
|
||||
to: 'orders',
|
||||
type: 'thrift',
|
||||
contract: 'billing.v1.OrderService/PlaceOrder',
|
||||
role: 'consumer',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.contracts).toHaveLength(2);
|
||||
expect(result.contracts.map((c) => c.contractId)).toEqual([
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
]);
|
||||
expect(result.crossLinks).toHaveLength(1);
|
||||
expect(result.crossLinks[0].type).toBe('thrift');
|
||||
expect(result.crossLinks[0].from.symbolUid).toBe(
|
||||
'manifest::gateway::thrift::billing.v1.OrderService/PlaceOrder',
|
||||
);
|
||||
expect(result.crossLinks[0].to.symbolUid).toBe(
|
||||
'manifest::orders::thrift::billing.v1.OrderService/PlaceOrder',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves custom manifest links by exact symbol name', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
|
||||
@@ -22,6 +22,16 @@ describe('normalizeContractId', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lowercases thrift package and service while preserving method case', () => {
|
||||
expect(normalizeContractId('thrift::Billing.V1.OrderService/PlaceOrder')).toBe(
|
||||
'thrift::billing.v1.orderservice/PlaceOrder',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves case for malformed thrift id with leading slash', () => {
|
||||
expect(normalizeContractId('thrift::/PlaceOrder')).toBe('thrift::/PlaceOrder');
|
||||
});
|
||||
|
||||
it('preserves case for malformed gRPC id with leading slash (no full-string lowercasing)', () => {
|
||||
expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing');
|
||||
});
|
||||
@@ -219,6 +229,26 @@ function makeGrpcContract(
|
||||
};
|
||||
}
|
||||
|
||||
function makeThriftContract(
|
||||
id: string,
|
||||
role: 'provider' | 'consumer',
|
||||
repo: string,
|
||||
overrides: Partial<StoredContract> = {},
|
||||
): StoredContract {
|
||||
return {
|
||||
contractId: id,
|
||||
type: 'thrift',
|
||||
role,
|
||||
symbolUid: `uid-${repo}-${id}`,
|
||||
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
|
||||
symbolName: `fn-${id}`,
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildProviderIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -258,6 +288,18 @@ describe('runExactMatch — gRPC wildcard handling', () => {
|
||||
expect(unmatched).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('test_runExactMatch_skips_thrift_wildcard_contracts', () => {
|
||||
const contracts: StoredContract[] = [
|
||||
makeThriftContract('thrift::billing.v1.OrderService/*', 'consumer', 'frontend'),
|
||||
makeThriftContract('thrift::billing.v1.OrderService/*', 'provider', 'backend'),
|
||||
];
|
||||
|
||||
const { matched, unmatched } = runExactMatch(contracts);
|
||||
|
||||
expect(matched).toHaveLength(0);
|
||||
expect(unmatched).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('test_runExactMatch_does_not_skip_http_wildcards', () => {
|
||||
const contracts: StoredContract[] = [
|
||||
{
|
||||
@@ -402,6 +444,161 @@ describe('runWildcardMatch', () => {
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].contractId).toBe('grpc::com.example.UserService/*');
|
||||
});
|
||||
|
||||
it('matches thrift fully-qualified service wildcard to a thrift provider method', () => {
|
||||
const consumer = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/*',
|
||||
'consumer',
|
||||
'frontend',
|
||||
);
|
||||
const provider = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].type).toBe('thrift');
|
||||
expect(matched[0].from.repo).toBe('frontend');
|
||||
expect(matched[0].to.repo).toBe('backend');
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('matches bare thrift service wildcard to a package-qualified thrift provider', () => {
|
||||
const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend');
|
||||
const provider = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].contractId).toBe('thrift::OrderService/*');
|
||||
});
|
||||
|
||||
it('does not match bare thrift service wildcard when multiple package-qualified services match', () => {
|
||||
const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend');
|
||||
const billingProvider = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'billing',
|
||||
);
|
||||
const salesProvider = makeThriftContract(
|
||||
'thrift::sales.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'sales',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([billingProvider, salesProvider]);
|
||||
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(0);
|
||||
expect(remaining).toEqual([consumer]);
|
||||
});
|
||||
|
||||
it('keeps fully-qualified thrift service wildcard matching when same bare service appears elsewhere', () => {
|
||||
const consumer = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/*',
|
||||
'consumer',
|
||||
'frontend',
|
||||
);
|
||||
const billingProvider = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'billing',
|
||||
);
|
||||
const salesProvider = makeThriftContract(
|
||||
'thrift::sales.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'sales',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([billingProvider, salesProvider]);
|
||||
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].to.repo).toBe('billing');
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('matches bare thrift service method to a package-qualified thrift provider method', () => {
|
||||
const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend');
|
||||
const provider = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched, unmatched } = runExactMatch([consumer, provider], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0].type).toBe('thrift');
|
||||
expect(matched[0].matchType).toBe('exact');
|
||||
expect(matched[0].contractId).toBe('thrift::OrderService/PlaceOrder');
|
||||
expect(matched[0].from.repo).toBe('frontend');
|
||||
expect(matched[0].to.repo).toBe('backend');
|
||||
expect(unmatched).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not match bare thrift service method to a different provider method', () => {
|
||||
const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend');
|
||||
const provider = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/GetOrderStatus',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched, unmatched } = runExactMatch([consumer, provider], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(0);
|
||||
expect(unmatched).toEqual([consumer, provider]);
|
||||
});
|
||||
|
||||
it('does not match bare thrift service method when multiple package-qualified providers match', () => {
|
||||
const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend');
|
||||
const billingProvider = makeThriftContract(
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'billing',
|
||||
);
|
||||
const salesProvider = makeThriftContract(
|
||||
'thrift::sales.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'sales',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([salesProvider, billingProvider]);
|
||||
const { matched, unmatched } = runExactMatch(
|
||||
[consumer, salesProvider, billingProvider],
|
||||
providerIndex,
|
||||
);
|
||||
|
||||
expect(matched).toHaveLength(0);
|
||||
expect(unmatched).toEqual([consumer, salesProvider, billingProvider]);
|
||||
});
|
||||
|
||||
it('does not match a thrift wildcard to a gRPC provider', () => {
|
||||
const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend');
|
||||
const provider = makeGrpcContract(
|
||||
'grpc::billing.v1.OrderService/PlaceOrder',
|
||||
'provider',
|
||||
'backend',
|
||||
);
|
||||
|
||||
const providerIndex = buildProviderIndex([provider]);
|
||||
const { matched, remaining } = runWildcardMatch([consumer], providerIndex);
|
||||
|
||||
expect(matched).toHaveLength(0);
|
||||
expect(remaining).toEqual([consumer]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNoisyContractFilter (via runExactMatch)', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('syncGroup', () => {
|
||||
detect: {
|
||||
http: true,
|
||||
grpc: false,
|
||||
thrift: false,
|
||||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
@@ -229,9 +230,11 @@ describe('syncGroup', () => {
|
||||
detect: {
|
||||
http: true,
|
||||
grpc: false,
|
||||
thrift: false,
|
||||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
workspace_deps: false,
|
||||
},
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
};
|
||||
@@ -264,6 +267,359 @@ describe('syncGroup', () => {
|
||||
expect(result.crossLinks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('runs thrift wildcard matching after exact matching and returns wildcard remaining', async () => {
|
||||
const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' });
|
||||
const provider: StoredContract = {
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-provider-place-order',
|
||||
symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' },
|
||||
symbolName: 'OrderService.PlaceOrder',
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo: 'app/provider',
|
||||
};
|
||||
const consumer: StoredContract = {
|
||||
contractId: 'thrift::OrderService/*',
|
||||
type: 'thrift',
|
||||
role: 'consumer',
|
||||
symbolUid: 'uid-consumer-order-service',
|
||||
symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' },
|
||||
symbolName: 'OrderClient',
|
||||
confidence: 0.8,
|
||||
meta: {},
|
||||
repo: 'app/consumer',
|
||||
};
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
extractorOverride: async () => [provider, consumer],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.crossLinks).toHaveLength(1);
|
||||
expect(result.crossLinks[0].matchType).toBe('wildcard');
|
||||
expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/*');
|
||||
expect(result.crossLinks[0].from.repo).toBe('app/consumer');
|
||||
expect(result.crossLinks[0].to.repo).toBe('app/provider');
|
||||
expect(result.unmatched).toEqual([provider]);
|
||||
});
|
||||
|
||||
it('keeps wildcard thrift links to multiple extracted IDL provider methods', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-wildcard-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'idl'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'idl', 'order.thrift'),
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
OrderResponse GetOrder(1: string orderId)
|
||||
}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const { ThriftExtractor } =
|
||||
await import('../../../src/core/group/extractors/thrift-extractor.js');
|
||||
const extractedProviders = (
|
||||
await new ThriftExtractor().extract(null, tmpDir, {
|
||||
id: 'provider-repo',
|
||||
path: 'app/provider',
|
||||
repoPath: tmpDir,
|
||||
storagePath: path.join(tmpDir, '.gitnexus'),
|
||||
})
|
||||
)
|
||||
.filter((c) => c.role === 'provider')
|
||||
.map(
|
||||
(c): StoredContract => ({
|
||||
...c,
|
||||
repo: 'app/provider',
|
||||
}),
|
||||
);
|
||||
|
||||
const consumer: StoredContract = {
|
||||
contractId: 'thrift::OrderService/*',
|
||||
type: 'thrift',
|
||||
role: 'consumer',
|
||||
symbolUid: 'manifest::app/consumer::thrift::OrderService/*',
|
||||
symbolRef: { filePath: 'group.yaml', name: 'OrderService' },
|
||||
symbolName: 'OrderService',
|
||||
confidence: 1,
|
||||
meta: {},
|
||||
repo: 'app/consumer',
|
||||
};
|
||||
|
||||
const result = await syncGroup(makeConfig({}), {
|
||||
extractorOverride: async () => [...extractedProviders, consumer],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.crossLinks).toHaveLength(2);
|
||||
expect(result.crossLinks.map((cl) => cl.to.symbolRef.name).sort()).toEqual([
|
||||
'OrderService.GetOrder',
|
||||
'OrderService.PlaceOrder',
|
||||
]);
|
||||
expect(new Set(result.crossLinks.map((cl) => cl.to.symbolUid)).size).toBe(2);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('matches weak thrift method consumers to namespace-qualified providers during sync', async () => {
|
||||
const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' });
|
||||
const provider: StoredContract = {
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-provider-place-order',
|
||||
symbolRef: { filePath: 'idl/order.thrift', name: 'OrderService.PlaceOrder' },
|
||||
symbolName: 'OrderService.PlaceOrder',
|
||||
confidence: 0.85,
|
||||
meta: {},
|
||||
repo: 'app/provider',
|
||||
};
|
||||
const consumer: StoredContract = {
|
||||
contractId: 'thrift::OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'consumer',
|
||||
symbolUid: 'uid-consumer-place-order',
|
||||
symbolRef: { filePath: 'src/BillingWorkflow.java', name: 'orderService.PlaceOrder' },
|
||||
symbolName: 'orderService.PlaceOrder',
|
||||
confidence: 0.45,
|
||||
meta: {},
|
||||
repo: 'app/consumer',
|
||||
};
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
extractorOverride: async () => [provider, consumer],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.crossLinks).toHaveLength(1);
|
||||
expect(result.crossLinks[0].matchType).toBe('exact');
|
||||
expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/PlaceOrder');
|
||||
expect(result.crossLinks[0].from.repo).toBe('app/consumer');
|
||||
expect(result.crossLinks[0].to.repo).toBe('app/provider');
|
||||
expect(result.unmatched).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps exact thrift links to extracted IDL and Java providers for same method', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-exact-'));
|
||||
fs.mkdirSync(path.join(tmpDir, 'idl'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, 'src', 'main', 'java', 'example'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'idl', 'order.thrift'),
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'src', 'main', 'java', 'example', 'IfaceOrderHandler.java'),
|
||||
`package example;
|
||||
|
||||
class IfaceOrderHandler implements OrderService.Iface {
|
||||
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
||||
return new PlaceOrderResponse();
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const { ThriftExtractor } =
|
||||
await import('../../../src/core/group/extractors/thrift-extractor.js');
|
||||
const extractedProviders = (
|
||||
await new ThriftExtractor().extract(null, tmpDir, {
|
||||
id: 'provider-repo',
|
||||
path: 'app/provider',
|
||||
repoPath: tmpDir,
|
||||
storagePath: path.join(tmpDir, '.gitnexus'),
|
||||
})
|
||||
)
|
||||
.filter((c) => c.role === 'provider')
|
||||
.map(
|
||||
(c): StoredContract => ({
|
||||
...c,
|
||||
repo: 'app/provider',
|
||||
}),
|
||||
);
|
||||
|
||||
const consumer: StoredContract = {
|
||||
contractId: 'thrift::OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'consumer',
|
||||
symbolUid: [
|
||||
'source-scan::thrift',
|
||||
'consumer',
|
||||
'OrderService/PlaceOrder',
|
||||
'src/BillingWorkflow.java',
|
||||
'orderService.PlaceOrder',
|
||||
].join('::'),
|
||||
symbolRef: { filePath: 'src/BillingWorkflow.java', name: 'orderService.PlaceOrder' },
|
||||
symbolName: 'orderService.PlaceOrder',
|
||||
confidence: 0.45,
|
||||
meta: {},
|
||||
repo: 'app/consumer',
|
||||
};
|
||||
|
||||
const result = await syncGroup(makeConfig({}), {
|
||||
extractorOverride: async () => [...extractedProviders, consumer],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.crossLinks).toHaveLength(2);
|
||||
expect(result.crossLinks.map((cl) => cl.to.symbolRef.filePath).sort()).toEqual([
|
||||
'idl/order.thrift',
|
||||
'src/main/java/example/IfaceOrderHandler.java',
|
||||
]);
|
||||
expect(new Set(result.crossLinks.map((cl) => cl.to.symbolUid)).size).toBe(2);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts thrift contracts during real sync when thrift detection is enabled', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-'));
|
||||
const storageDir = path.join(tmpDir, '.gitnexus');
|
||||
fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'idl'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'src'), { recursive: true });
|
||||
fs.mkdirSync(storageDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, 'services', 'billing', 'package.json'), '{}');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'services', 'billing', 'src', 'BillingWorkflow.java'),
|
||||
'package example; class BillingWorkflow {}',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'services', 'billing', 'idl', 'order.thrift'),
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
|
||||
const config = makeConfig({ 'services/billing': 'billing-repo' });
|
||||
config.detect.http = false;
|
||||
config.detect.thrift = true;
|
||||
|
||||
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
||||
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
||||
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined);
|
||||
|
||||
try {
|
||||
const result = await syncGroup(config, {
|
||||
resolveRepoHandle: async (_name, groupPath) => ({
|
||||
id: 'billing-repo',
|
||||
path: groupPath,
|
||||
repoPath: tmpDir,
|
||||
storagePath: storageDir,
|
||||
}),
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.missingRepos).toHaveLength(0);
|
||||
expect(result.contracts).toHaveLength(1);
|
||||
expect(result.contracts[0]).toMatchObject({
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'provider',
|
||||
repo: 'services/billing',
|
||||
service: 'services/billing',
|
||||
symbolRef: {
|
||||
filePath: 'services/billing/idl/order.thrift',
|
||||
name: 'OrderService.PlaceOrder',
|
||||
},
|
||||
});
|
||||
expect(initSpy).toHaveBeenCalledWith('billing-repo', path.join(storageDir, 'lbug'));
|
||||
expect(closeSpy).toHaveBeenCalledWith('billing-repo');
|
||||
} finally {
|
||||
initSpy.mockRestore();
|
||||
closeSpy.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not extract thrift contracts during real sync when thrift detection is disabled', async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-off-'));
|
||||
const storageDir = path.join(tmpDir, '.gitnexus');
|
||||
fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'idl'), { recursive: true });
|
||||
fs.mkdirSync(storageDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'services', 'billing', 'idl', 'order.thrift'),
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
|
||||
const config = makeConfig({ 'services/billing': 'billing-repo' });
|
||||
config.detect.http = false;
|
||||
config.detect.thrift = false;
|
||||
|
||||
const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js');
|
||||
const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined);
|
||||
const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined);
|
||||
|
||||
try {
|
||||
const result = await syncGroup(config, {
|
||||
resolveRepoHandle: async (_name, groupPath) => ({
|
||||
id: 'billing-repo',
|
||||
path: groupPath,
|
||||
repoPath: tmpDir,
|
||||
storagePath: storageDir,
|
||||
}),
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.missingRepos).toHaveLength(0);
|
||||
expect(result.contracts).toHaveLength(0);
|
||||
} finally {
|
||||
initSpy.mockRestore();
|
||||
closeSpy.mockRestore();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('dedupes duplicate wildcard cross-links during sync', async () => {
|
||||
const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' });
|
||||
const provider: StoredContract = {
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-provider-place-order',
|
||||
symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' },
|
||||
symbolName: 'OrderService.PlaceOrder',
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
repo: 'app/provider',
|
||||
};
|
||||
const duplicateProvider: StoredContract = {
|
||||
...provider,
|
||||
confidence: 0.7,
|
||||
};
|
||||
const consumer: StoredContract = {
|
||||
contractId: 'thrift::OrderService/*',
|
||||
type: 'thrift',
|
||||
role: 'consumer',
|
||||
symbolUid: 'uid-consumer-order-service',
|
||||
symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' },
|
||||
symbolName: 'OrderClient',
|
||||
confidence: 0.8,
|
||||
meta: {},
|
||||
repo: 'app/consumer',
|
||||
};
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
extractorOverride: async () => [provider, duplicateProvider, consumer],
|
||||
skipWrite: true,
|
||||
});
|
||||
|
||||
expect(result.crossLinks).toHaveLength(1);
|
||||
expect(result.crossLinks[0].matchType).toBe('wildcard');
|
||||
});
|
||||
|
||||
it('manifest links referencing unknown repos still produce cross-links via synthetic UIDs', async () => {
|
||||
const links: GroupManifestLink[] = [
|
||||
{
|
||||
@@ -285,9 +641,11 @@ describe('syncGroup', () => {
|
||||
detect: {
|
||||
http: true,
|
||||
grpc: false,
|
||||
thrift: false,
|
||||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
workspace_deps: false,
|
||||
},
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
};
|
||||
@@ -350,6 +708,7 @@ describe('syncGroup', () => {
|
||||
detect: {
|
||||
http: false,
|
||||
grpc: false,
|
||||
thrift: false,
|
||||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
@@ -506,6 +865,7 @@ describe('syncGroup', () => {
|
||||
detect: {
|
||||
http: false,
|
||||
grpc: false,
|
||||
thrift: false,
|
||||
topics: false,
|
||||
shared_libs: false,
|
||||
embedding_fallback: false,
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
ThriftExtractor,
|
||||
buildThriftContext,
|
||||
thriftMethodContractId,
|
||||
thriftServiceContractId,
|
||||
} from '../../../src/core/group/extractors/thrift-extractor.js';
|
||||
import type { RepoHandle } from '../../../src/core/group/types.js';
|
||||
|
||||
describe('ThriftExtractor', () => {
|
||||
let tmpDir: string;
|
||||
let extractor: ThriftExtractor;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-thrift-'));
|
||||
extractor = new ThriftExtractor();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeFile(relPath: string, content: string): void {
|
||||
const full = path.join(tmpDir, relPath);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
|
||||
const makeRepo = (repoPath: string): RepoHandle => ({
|
||||
id: 'test-repo',
|
||||
path: 'test/app',
|
||||
repoPath,
|
||||
storagePath: path.join(repoPath, '.gitnexus'),
|
||||
});
|
||||
|
||||
it('test_extract_thrift_single_method_returns_idl_provider', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts).toHaveLength(1);
|
||||
expect(contracts[0]).toMatchObject({
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'provider',
|
||||
symbolName: 'OrderService.PlaceOrder',
|
||||
confidence: 0.85,
|
||||
meta: {
|
||||
namespace: 'billing.v1',
|
||||
service: 'OrderService',
|
||||
method: 'PlaceOrder',
|
||||
source: 'thrift_idl',
|
||||
},
|
||||
});
|
||||
expect(contracts[0].symbolRef).toEqual({
|
||||
filePath: 'idl/order.thrift',
|
||||
name: 'OrderService.PlaceOrder',
|
||||
});
|
||||
});
|
||||
|
||||
it('test_extract_thrift_multiple_services_and_methods_returns_all', async () => {
|
||||
writeFile(
|
||||
'contracts/orders.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
OrderStatus GetOrderStatus(1: string orderId)
|
||||
}
|
||||
|
||||
service InvoiceService {
|
||||
Invoice CreateInvoice(1: string orderId)
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts.map((c) => c.contractId).sort()).toEqual([
|
||||
'thrift::billing.v1.InvoiceService/CreateInvoice',
|
||||
'thrift::billing.v1.OrderService/GetOrderStatus',
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_thrift_prefers_java_namespace_over_other_namespaces', async () => {
|
||||
writeFile(
|
||||
'order.thrift',
|
||||
`namespace py billing_python.v1
|
||||
namespace java billing.v1
|
||||
namespace go billinggo
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts[0].contractId).toBe('thrift::billing.v1.OrderService/PlaceOrder');
|
||||
expect(contracts[0].meta.namespace).toBe('billing.v1');
|
||||
});
|
||||
|
||||
it('test_extract_thrift_uses_first_non_java_namespace_when_java_missing', async () => {
|
||||
writeFile(
|
||||
'order.thrift',
|
||||
`namespace py billing_python.v1
|
||||
namespace go billinggo
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts[0].contractId).toBe('thrift::billing_python.v1.OrderService/PlaceOrder');
|
||||
expect(contracts[0].meta.namespace).toBe('billing_python.v1');
|
||||
});
|
||||
|
||||
it('test_extract_thrift_without_namespace_uses_service_only', async () => {
|
||||
writeFile(
|
||||
'order.thrift',
|
||||
`service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts[0].contractId).toBe('thrift::OrderService/PlaceOrder');
|
||||
expect(contracts[0].meta.namespace).toBe('');
|
||||
});
|
||||
|
||||
it('test_extract_thrift_ignores_braces_inside_comments_and_strings', async () => {
|
||||
writeFile(
|
||||
'idl/tricky.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
// A comment with } should not close the service.
|
||||
/* A block comment with { and } should not affect depth. */
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
const string NOTE = "literal with } and { braces"
|
||||
OrderStatus GetOrderStatus(1: string orderId)
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts.map((c) => c.symbolName).sort()).toEqual([
|
||||
'OrderService.GetOrderStatus',
|
||||
'OrderService.PlaceOrder',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_thrift_malformed_unclosed_service_is_skipped', async () => {
|
||||
writeFile(
|
||||
'idl/broken.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
`,
|
||||
);
|
||||
|
||||
await expect(extractor.extract(null, tmpDir, makeRepo(tmpDir))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('test_extract_repo_without_thrift_returns_empty', async () => {
|
||||
writeFile('src/index.ts', 'console.log("hello")');
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts).toEqual([]);
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_consumers_from_iface_client_and_service_fields', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/BillingWorkflow.java',
|
||||
`package example;
|
||||
|
||||
class BillingWorkflow {
|
||||
private OrderService.Iface orderService;
|
||||
private OrderService.Client orderClient;
|
||||
private OrderService generatedOrderService;
|
||||
|
||||
void submit(PlaceOrderRequest request) throws Exception {
|
||||
orderService.PlaceOrder(request);
|
||||
orderClient.PlaceOrder(request);
|
||||
generatedOrderService.PlaceOrder(request);
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const consumers = contracts
|
||||
.filter((c) => c.role === 'consumer')
|
||||
.sort((a, b) => a.symbolName.localeCompare(b.symbolName));
|
||||
|
||||
expect(consumers).toHaveLength(3);
|
||||
expect(consumers.map((c) => c.symbolName)).toEqual([
|
||||
'generatedOrderService.PlaceOrder',
|
||||
'orderClient.PlaceOrder',
|
||||
'orderService.PlaceOrder',
|
||||
]);
|
||||
for (const contract of consumers) {
|
||||
expect(contract).toMatchObject({
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'consumer',
|
||||
confidence: 0.75,
|
||||
meta: {
|
||||
namespace: 'billing.v1',
|
||||
service: 'OrderService',
|
||||
method: 'PlaceOrder',
|
||||
source: 'java_thrift_consumer',
|
||||
},
|
||||
});
|
||||
expect(contract.symbolRef.filePath).toBe('src/main/java/example/BillingWorkflow.java');
|
||||
}
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_consumers_from_this_field_access', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/BillingWorkflow.java',
|
||||
`package example;
|
||||
|
||||
class BillingWorkflow {
|
||||
private OrderService.Client orderClient;
|
||||
|
||||
void submit(PlaceOrderRequest request) throws Exception {
|
||||
this.orderClient.PlaceOrder(request);
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers).toHaveLength(1);
|
||||
expect(consumers[0]).toMatchObject({
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'consumer',
|
||||
symbolName: 'orderClient.PlaceOrder',
|
||||
confidence: 0.75,
|
||||
meta: {
|
||||
namespace: 'billing.v1',
|
||||
service: 'OrderService',
|
||||
method: 'PlaceOrder',
|
||||
source: 'java_thrift_consumer',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_consumers_from_fully_qualified_generated_types', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/BillingWorkflow.java',
|
||||
`package example;
|
||||
|
||||
class BillingWorkflow {
|
||||
private billing.v1.OrderService.Iface orderService;
|
||||
private billing.v1.OrderService.Client orderClient;
|
||||
|
||||
void submit(PlaceOrderRequest request) throws Exception {
|
||||
orderService.PlaceOrder(request);
|
||||
orderClient.PlaceOrder(request);
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const consumers = contracts
|
||||
.filter((c) => c.role === 'consumer')
|
||||
.sort((a, b) => a.symbolName.localeCompare(b.symbolName));
|
||||
|
||||
expect(consumers).toHaveLength(2);
|
||||
expect(consumers.map((c) => c.symbolName)).toEqual([
|
||||
'orderClient.PlaceOrder',
|
||||
'orderService.PlaceOrder',
|
||||
]);
|
||||
expect(new Set(consumers.map((c) => c.contractId))).toEqual(
|
||||
new Set(['thrift::billing.v1.OrderService/PlaceOrder']),
|
||||
);
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_consumers_from_local_variables', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/BillingWorker.java',
|
||||
`package example;
|
||||
|
||||
class BillingWorker {
|
||||
void submit(OrderService.Iface iface, OrderService.Client client, OrderService service) throws Exception {
|
||||
OrderService.Iface orderService = iface;
|
||||
OrderService.Client orderClient = client;
|
||||
OrderService generatedOrderService = service;
|
||||
|
||||
orderService.PlaceOrder(new PlaceOrderRequest());
|
||||
orderClient.PlaceOrder(new PlaceOrderRequest());
|
||||
generatedOrderService.PlaceOrder(new PlaceOrderRequest());
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const consumers = contracts.filter((c) => c.role === 'consumer');
|
||||
|
||||
expect(consumers.map((c) => c.symbolName).sort()).toEqual([
|
||||
'generatedOrderService.PlaceOrder',
|
||||
'orderClient.PlaceOrder',
|
||||
'orderService.PlaceOrder',
|
||||
]);
|
||||
expect(new Set(consumers.map((c) => c.contractId))).toEqual(
|
||||
new Set(['thrift::billing.v1.OrderService/PlaceOrder']),
|
||||
);
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_consumers_resolve_receiver_by_nearest_scope', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}
|
||||
|
||||
service InvoiceService {
|
||||
Invoice CreateInvoice(1: string orderId)
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/BillingWorker.java',
|
||||
`package example;
|
||||
|
||||
class BillingWorker {
|
||||
void submitOrder(OrderService.Iface client, PlaceOrderRequest request) throws Exception {
|
||||
client.PlaceOrder(request);
|
||||
}
|
||||
|
||||
void submitInvoice() throws Exception {
|
||||
InvoiceService.Client client = new InvoiceService.Client(null);
|
||||
client.CreateInvoice("order-1");
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const consumers = contracts
|
||||
.filter((c) => c.role === 'consumer')
|
||||
.sort((a, b) => a.contractId.localeCompare(b.contractId));
|
||||
|
||||
expect(consumers.map((c) => c.contractId)).toEqual([
|
||||
'thrift::billing.v1.InvoiceService/CreateInvoice',
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
]);
|
||||
expect(consumers.map((c) => c.symbolName).sort()).toEqual([
|
||||
'client.CreateInvoice',
|
||||
'client.PlaceOrder',
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_providers_from_iface_and_service_implements', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/IfaceOrderHandler.java',
|
||||
`package example;
|
||||
|
||||
class IfaceOrderHandler implements OrderService.Iface {
|
||||
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
||||
return new PlaceOrderResponse();
|
||||
}
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/GeneratedOrderHandler.java',
|
||||
`package example;
|
||||
|
||||
class GeneratedOrderHandler implements OrderService {
|
||||
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
||||
return new PlaceOrderResponse();
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts
|
||||
.filter((c) => c.meta.source === 'java_thrift_provider')
|
||||
.sort((a, b) => a.symbolRef.filePath.localeCompare(b.symbolRef.filePath));
|
||||
|
||||
expect(providers).toHaveLength(2);
|
||||
for (const contract of providers) {
|
||||
expect(contract).toMatchObject({
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'provider',
|
||||
symbolName: 'OrderService.PlaceOrder',
|
||||
confidence: 0.8,
|
||||
meta: {
|
||||
namespace: 'billing.v1',
|
||||
service: 'OrderService',
|
||||
method: 'PlaceOrder',
|
||||
source: 'java_thrift_provider',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('test_extract_thrift_source_scan_contracts_have_stable_distinct_symbol_uids', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/IfaceOrderHandler.java',
|
||||
`package example;
|
||||
|
||||
class IfaceOrderHandler implements OrderService.Iface {
|
||||
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
||||
return new PlaceOrderResponse();
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const first = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const second = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = first
|
||||
.filter((c) => c.role === 'provider')
|
||||
.sort((a, b) => a.symbolRef.filePath.localeCompare(b.symbolRef.filePath));
|
||||
const repeatedProviders = second
|
||||
.filter((c) => c.role === 'provider')
|
||||
.sort((a, b) => a.symbolRef.filePath.localeCompare(b.symbolRef.filePath));
|
||||
|
||||
expect(providers).toHaveLength(2);
|
||||
expect(providers.map((c) => c.symbolUid)).toEqual(repeatedProviders.map((c) => c.symbolUid));
|
||||
expect(providers.every((c) => c.symbolUid.length > 0)).toBe(true);
|
||||
expect(new Set(providers.map((c) => c.symbolUid)).size).toBe(2);
|
||||
expect(providers.every((c) => !c.symbolUid.includes('::thrift::billing.v1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_providers_from_fully_qualified_generated_iface', async () => {
|
||||
writeFile(
|
||||
'idl/order.thrift',
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
}`,
|
||||
);
|
||||
writeFile(
|
||||
'src/main/java/example/IfaceOrderHandler.java',
|
||||
`package example;
|
||||
|
||||
class IfaceOrderHandler implements billing.v1.OrderService.Iface {
|
||||
public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) {
|
||||
return new PlaceOrderResponse();
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
const providers = contracts.filter((c) => c.meta.source === 'java_thrift_provider');
|
||||
|
||||
expect(providers).toHaveLength(1);
|
||||
expect(providers[0]).toMatchObject({
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'provider',
|
||||
symbolName: 'OrderService.PlaceOrder',
|
||||
confidence: 0.8,
|
||||
meta: {
|
||||
namespace: 'billing.v1',
|
||||
service: 'OrderService',
|
||||
method: 'PlaceOrder',
|
||||
source: 'java_thrift_provider',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_consumer_without_idl_emits_weak_method_contract', async () => {
|
||||
writeFile(
|
||||
'src/main/java/example/BillingWorkflow.java',
|
||||
`package example;
|
||||
|
||||
class BillingWorkflow {
|
||||
private OrderService.Iface orderService;
|
||||
|
||||
void submit(PlaceOrderRequest request) throws Exception {
|
||||
orderService.PlaceOrder(request);
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts).toHaveLength(1);
|
||||
expect(contracts[0]).toMatchObject({
|
||||
contractId: 'thrift::OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'consumer',
|
||||
symbolName: 'orderService.PlaceOrder',
|
||||
confidence: 0.45,
|
||||
meta: {
|
||||
service: 'OrderService',
|
||||
method: 'PlaceOrder',
|
||||
source: 'java_thrift_consumer_weak',
|
||||
},
|
||||
});
|
||||
expect(contracts[0].symbolRef.filePath).toBe('src/main/java/example/BillingWorkflow.java');
|
||||
});
|
||||
|
||||
it('test_extract_java_thrift_direct_service_consumer_without_idl_returns_empty', async () => {
|
||||
writeFile(
|
||||
'src/main/java/example/PaymentWorkflow.java',
|
||||
`package example;
|
||||
|
||||
class PaymentWorkflow {
|
||||
private PaymentService paymentService;
|
||||
|
||||
void submit() {
|
||||
paymentService.charge();
|
||||
}
|
||||
}`,
|
||||
);
|
||||
|
||||
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
|
||||
|
||||
expect(contracts).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildThriftContext', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-thrift-context-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('test_buildThriftContext_parses_namespace_service_methods_and_path', async () => {
|
||||
await fsp.mkdir(path.join(tmpDir, 'idl'), { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, 'idl', 'order.thrift'),
|
||||
`namespace java billing.v1
|
||||
|
||||
service OrderService {
|
||||
PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request)
|
||||
OrderStatus GetOrderStatus(1: string orderId)
|
||||
}`,
|
||||
);
|
||||
|
||||
const context = await buildThriftContext(tmpDir);
|
||||
|
||||
expect(context.namespacesByThrift.get('idl/order.thrift')).toBe('billing.v1');
|
||||
expect(context.servicesByName.get('OrderService')).toEqual([
|
||||
{
|
||||
namespace: 'billing.v1',
|
||||
serviceName: 'OrderService',
|
||||
methods: ['PlaceOrder', 'GetOrderStatus'],
|
||||
thriftPath: 'idl/order.thrift',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('test_buildThriftContext_without_files_returns_empty_maps', async () => {
|
||||
const context = await buildThriftContext(tmpDir);
|
||||
|
||||
expect(context.namespacesByThrift.size).toBe(0);
|
||||
expect(context.servicesByName.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Thrift contract id helpers', () => {
|
||||
it('test_thriftMethodContractId_with_namespace', () => {
|
||||
expect(thriftMethodContractId('billing.v1', 'OrderService', 'PlaceOrder')).toBe(
|
||||
'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
);
|
||||
});
|
||||
|
||||
it('test_thriftMethodContractId_without_namespace', () => {
|
||||
expect(thriftMethodContractId('', 'OrderService', 'PlaceOrder')).toBe(
|
||||
'thrift::OrderService/PlaceOrder',
|
||||
);
|
||||
});
|
||||
|
||||
it('test_thriftServiceContractId_with_namespace', () => {
|
||||
expect(thriftServiceContractId('billing.v1', 'OrderService')).toBe(
|
||||
'thrift::billing.v1.OrderService/*',
|
||||
);
|
||||
});
|
||||
|
||||
it('test_thriftServiceContractId_without_namespace', () => {
|
||||
expect(thriftServiceContractId('', 'OrderService')).toBe('thrift::OrderService/*');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ describe('Group types', () => {
|
||||
detect: {
|
||||
http: true,
|
||||
grpc: true,
|
||||
thrift: true,
|
||||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
@@ -63,6 +64,41 @@ describe('Group types', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('ExtractedContract accepts thrift contract type', () => {
|
||||
const contract: ExtractedContract = {
|
||||
contractId: 'thrift::billing.v1.OrderService/PlaceOrder',
|
||||
type: 'thrift',
|
||||
role: 'provider',
|
||||
symbolUid: 'uid-thrift',
|
||||
symbolRef: { filePath: 'idl/order.thrift', name: 'OrderService.PlaceOrder' },
|
||||
symbolName: 'OrderService.PlaceOrder',
|
||||
confidence: 0.9,
|
||||
meta: {},
|
||||
};
|
||||
expect(contract.type).toBe('thrift');
|
||||
});
|
||||
|
||||
it('DetectConfig includes thrift toggle', () => {
|
||||
const config: GroupConfig = {
|
||||
version: 1,
|
||||
name: 'company',
|
||||
description: 'All company microservices',
|
||||
repos: { orders: 'orders-repo' },
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: {
|
||||
http: true,
|
||||
grpc: true,
|
||||
thrift: true,
|
||||
topics: true,
|
||||
shared_libs: true,
|
||||
embedding_fallback: true,
|
||||
},
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
};
|
||||
expect(config.detect.thrift).toBe(true);
|
||||
});
|
||||
|
||||
it('CrossLink stores match metadata', () => {
|
||||
const link: CrossLink = {
|
||||
from: {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Structural + behavioural tests for the WAL-flush / close helpers (#1376).
|
||||
*
|
||||
* After the review-driven refactor, the module exposes two layers:
|
||||
* - flushWAL — CHECKPOINT only (connection stays open)
|
||||
* - safeClose — flushWAL + conn.close + db.close
|
||||
*
|
||||
* closeLbug delegates to safeClose for the CHECKPOINT + close step and
|
||||
* then resets module-level state (currentDbPath, ftsLoaded, etc.).
|
||||
*
|
||||
* The structural tests read the adapter source and verify delegation
|
||||
* contracts so a future refactor that inlines close logic is caught.
|
||||
*
|
||||
* The behavioural tests import flushWAL directly and exercise the
|
||||
* runtime null-guard path (conn is null at module load) so a future
|
||||
* refactor that accidentally throws is caught immediately.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { flushWAL } from '../../src/core/lbug/lbug-adapter.js';
|
||||
|
||||
describe('flushWAL / safeClose — consolidation guard (#1376)', () => {
|
||||
let adapterSource: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
adapterSource = await fs.readFile(
|
||||
path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('exports flushWAL (CHECKPOINT-only helper)', () => {
|
||||
expect(adapterSource).toMatch(/export const flushWAL/);
|
||||
});
|
||||
|
||||
it('exports safeClose (CHECKPOINT + close helper)', () => {
|
||||
expect(adapterSource).toMatch(/export const safeClose/);
|
||||
});
|
||||
|
||||
it('safeClose delegates to flushWAL for the CHECKPOINT step', () => {
|
||||
const safeCloseBody = adapterSource.slice(adapterSource.indexOf('export const safeClose'));
|
||||
expect(safeCloseBody).toMatch(/await flushWAL\(\)/);
|
||||
});
|
||||
|
||||
it('closeLbug delegates to safeClose instead of inlining conn.close/db.close', () => {
|
||||
const closeLbugBody = adapterSource.slice(adapterSource.indexOf('export const closeLbug'));
|
||||
expect(closeLbugBody).toMatch(/await safeClose\(\)/);
|
||||
// closeLbug must NOT contain its own conn.close() or db.close() — those
|
||||
// live exclusively inside safeClose now.
|
||||
const closeLbugBlock = closeLbugBody.slice(0, closeLbugBody.indexOf('export const', 1) >>> 0);
|
||||
expect(closeLbugBlock).not.toMatch(/conn\.close\(\)/);
|
||||
expect(closeLbugBlock).not.toMatch(/db\.close\(\)/);
|
||||
});
|
||||
|
||||
it('flushWAL is the only place that issues conn.query(CHECKPOINT)', () => {
|
||||
const matches = adapterSource.match(/conn\.query\('CHECKPOINT'\)/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('conn.close() only appears inside safeClose (with eslint-disable)', () => {
|
||||
// Every conn.close() in the adapter must live inside safeClose, guarded
|
||||
// by the eslint-disable comment. Count occurrences to catch leaks.
|
||||
const matches = adapterSource.match(/await conn\.close\(\)/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('db.close() only appears inside safeClose (with eslint-disable)', () => {
|
||||
const matches = adapterSource.match(/await db\.close\(\)/g) ?? [];
|
||||
expect(matches.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Behavioural tests — exercise flushWAL at runtime rather than just
|
||||
// grepping source text. At module load `conn` is null, so these hit
|
||||
// the early-return guard without needing a real LadybugDB instance.
|
||||
describe('flushWAL — runtime behaviour', () => {
|
||||
it('resolves without error when no connection is open', async () => {
|
||||
// conn is null at module load — flushWAL must not throw.
|
||||
await expect(flushWAL()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('can be called repeatedly without throwing (idempotent)', async () => {
|
||||
await flushWAL();
|
||||
await flushWAL();
|
||||
// No assertion needed beyond "did not throw".
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { withMcpWrite, isMcpWrite, createStdoutSentinel } from '../../src/mcp/stdio-context.js';
|
||||
|
||||
interface CapturedWrite {
|
||||
target: 'stdout' | 'stderr';
|
||||
payload: string;
|
||||
}
|
||||
|
||||
function makeCapture() {
|
||||
const captured: CapturedWrite[] = [];
|
||||
const realStdoutWrite = (chunk: any) => {
|
||||
captured.push({
|
||||
target: 'stdout',
|
||||
payload: Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk),
|
||||
});
|
||||
return true;
|
||||
};
|
||||
const realStderrWrite = (chunk: any) => {
|
||||
captured.push({
|
||||
target: 'stderr',
|
||||
payload: Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk),
|
||||
});
|
||||
return true;
|
||||
};
|
||||
return { captured, realStdoutWrite, realStderrWrite };
|
||||
}
|
||||
|
||||
function joinedStderr(captured: CapturedWrite[]): string {
|
||||
return captured
|
||||
.filter((c) => c.target === 'stderr')
|
||||
.map((c) => c.payload)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function joinedStdout(captured: CapturedWrite[]): string {
|
||||
return captured
|
||||
.filter((c) => c.target === 'stdout')
|
||||
.map((c) => c.payload)
|
||||
.join('');
|
||||
}
|
||||
|
||||
describe('mcp/stdio-context — withMcpWrite / isMcpWrite', () => {
|
||||
it('isMcpWrite returns false outside withMcpWrite', () => {
|
||||
expect(isMcpWrite()).toBe(false);
|
||||
});
|
||||
|
||||
it('isMcpWrite returns true inside withMcpWrite', () => {
|
||||
let inside: boolean | undefined;
|
||||
withMcpWrite(() => {
|
||||
inside = isMcpWrite();
|
||||
});
|
||||
expect(inside).toBe(true);
|
||||
});
|
||||
|
||||
it('isMcpWrite returns false again after withMcpWrite returns', () => {
|
||||
withMcpWrite(() => {
|
||||
// noop
|
||||
});
|
||||
expect(isMcpWrite()).toBe(false);
|
||||
});
|
||||
|
||||
it('withMcpWrite returns the inner value', () => {
|
||||
const v = withMcpWrite(() => 42);
|
||||
expect(v).toBe(42);
|
||||
});
|
||||
|
||||
it('nested withMcpWrite stays tagged', () => {
|
||||
let deep: boolean | undefined;
|
||||
withMcpWrite(() => {
|
||||
withMcpWrite(() => {
|
||||
deep = isMcpWrite();
|
||||
});
|
||||
});
|
||||
expect(deep).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mcp/stdio-context — createStdoutSentinel', () => {
|
||||
let capture: ReturnType<typeof makeCapture>;
|
||||
|
||||
beforeEach(() => {
|
||||
capture = makeCapture();
|
||||
});
|
||||
|
||||
it('passes writes through to real stdout when called inside withMcpWrite', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
withMcpWrite(() => {
|
||||
sentinel.write('Content-Length: 42\r\n\r\n{"jsonrpc":"2.0"}');
|
||||
});
|
||||
expect(joinedStdout(capture.captured)).toBe('Content-Length: 42\r\n\r\n{"jsonrpc":"2.0"}');
|
||||
expect(joinedStderr(capture.captured)).toBe('');
|
||||
});
|
||||
|
||||
it('passes newline-terminated JSON-RPC frames through to stdout when tagged', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
withMcpWrite(() => {
|
||||
sentinel.write('{"jsonrpc":"2.0","id":1}\n');
|
||||
});
|
||||
expect(joinedStdout(capture.captured)).toBe('{"jsonrpc":"2.0","id":1}\n');
|
||||
});
|
||||
|
||||
it('passes Buffer payloads through to stdout when tagged', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
withMcpWrite(() => {
|
||||
sentinel.write(Buffer.from('payload', 'utf8'));
|
||||
});
|
||||
expect(joinedStdout(capture.captured)).toBe('payload');
|
||||
});
|
||||
|
||||
it('redirects untagged writes to stderr with the [mcp:stdout-redirect] prefix', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
sentinel.write('rogue output\n');
|
||||
|
||||
const stderr = joinedStderr(capture.captured);
|
||||
expect(stderr).toContain('[mcp:stdout-redirect]');
|
||||
expect(stderr).toContain('rogue output');
|
||||
expect(joinedStdout(capture.captured)).toBe('');
|
||||
});
|
||||
|
||||
it('emits a one-shot startup warning on the first redirect only', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
sentinel.write('first\n');
|
||||
sentinel.write('second\n');
|
||||
|
||||
const stderr = joinedStderr(capture.captured);
|
||||
const warningMatches = stderr.match(/sentinel triggered/g) ?? [];
|
||||
expect(warningMatches.length).toBe(1);
|
||||
});
|
||||
|
||||
it('truncates redirect payload to maxBytes (default 200) and reports the overflow', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
const huge = 'x'.repeat(1024);
|
||||
sentinel.write(huge);
|
||||
|
||||
const stderr = joinedStderr(capture.captured);
|
||||
// The redirected payload portion should not contain all 1024 x's.
|
||||
expect(stderr.includes('x'.repeat(1024))).toBe(false);
|
||||
expect(stderr).toContain('x'.repeat(200));
|
||||
expect(stderr).toMatch(/\(\+\d+ bytes truncated\)/);
|
||||
});
|
||||
|
||||
it('respects a custom maxBytes', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
maxBytes: 8,
|
||||
});
|
||||
sentinel.write('abcdefghijklmnop');
|
||||
|
||||
const stderr = joinedStderr(capture.captured);
|
||||
expect(stderr).toContain('abcdefgh');
|
||||
expect(stderr.includes('abcdefghi')).toBe(false);
|
||||
expect(stderr).toContain('truncated');
|
||||
});
|
||||
|
||||
it('rate-limits redirects to maxRedirects (default 10) — extras are suppressed', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
for (let i = 0; i < 15; i += 1) {
|
||||
sentinel.write(`line-${i}\n`);
|
||||
}
|
||||
|
||||
const stderr = joinedStderr(capture.captured);
|
||||
// First 10 lines are surfaced; lines 10-14 are suppressed.
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
expect(stderr).toContain(`line-${i}`);
|
||||
}
|
||||
for (let i = 10; i < 15; i += 1) {
|
||||
expect(stderr.includes(`line-${i}`)).toBe(false);
|
||||
}
|
||||
expect(sentinel.stats().redirected).toBe(10);
|
||||
expect(sentinel.stats().suppressed).toBe(5);
|
||||
});
|
||||
|
||||
it('flushSummary emits the counter line when redirects occurred', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
sentinel.write('one\n');
|
||||
sentinel.write('two\n');
|
||||
sentinel.flushSummary();
|
||||
|
||||
const stderr = joinedStderr(capture.captured);
|
||||
expect(stderr).toMatch(/summary:\s*2 redirected,\s*0 suppressed/);
|
||||
});
|
||||
|
||||
it('flushSummary is silent when no redirects occurred', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
sentinel.flushSummary();
|
||||
|
||||
expect(joinedStderr(capture.captured)).toBe('');
|
||||
});
|
||||
|
||||
it('returns true for empty writes and never throws', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
expect(() => sentinel.write('')).not.toThrow();
|
||||
expect(() => sentinel.write(undefined as any)).not.toThrow();
|
||||
expect(sentinel.write('')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles plain Uint8Array (not Buffer) correctly when redirecting', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
// Plain Uint8Array — Buffer.isBuffer returns false. Bytes spell "hi\n".
|
||||
const u8 = new Uint8Array([0x68, 0x69, 0x0a]);
|
||||
sentinel.write(u8);
|
||||
|
||||
const stderr = joinedStderr(capture.captured);
|
||||
expect(stderr).toContain('hi');
|
||||
expect(stderr).not.toMatch(/\b104,\s*105/); // not falling through to String(chunk) → "104,105,10"
|
||||
});
|
||||
|
||||
it('invokes the Writable callback (if provided) for redirected writes', async () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
let called = false;
|
||||
let cbErr: Error | null | undefined = undefined;
|
||||
sentinel.write('rogue\n', 'utf8', (err: Error | null | undefined) => {
|
||||
called = true;
|
||||
cbErr = err;
|
||||
});
|
||||
// Callback fires on next tick, not sync.
|
||||
expect(called).toBe(false);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(called).toBe(true);
|
||||
expect(cbErr).toBeNull();
|
||||
});
|
||||
|
||||
it('invokes the Writable callback for redirected writes when called past the rate-limit cap', async () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
maxRedirects: 1,
|
||||
});
|
||||
sentinel.write('first\n');
|
||||
let called = false;
|
||||
sentinel.write('second\n', () => {
|
||||
called = true;
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
it('handles a multi-call sequence where some writes are tagged and some are not', () => {
|
||||
const sentinel = createStdoutSentinel({
|
||||
realStdoutWrite: capture.realStdoutWrite,
|
||||
realStderrWrite: capture.realStderrWrite,
|
||||
});
|
||||
|
||||
withMcpWrite(() => sentinel.write('{"frame1":1}\n'));
|
||||
sentinel.write('rogue-1\n');
|
||||
withMcpWrite(() => sentinel.write('{"frame2":2}\n'));
|
||||
sentinel.write('rogue-2\n');
|
||||
|
||||
expect(joinedStdout(capture.captured)).toBe('{"frame1":1}\n{"frame2":2}\n');
|
||||
const stderr = joinedStderr(capture.captured);
|
||||
expect(stderr).toContain('rogue-1');
|
||||
expect(stderr).toContain('rogue-2');
|
||||
});
|
||||
});
|
||||
@@ -11,10 +11,10 @@
|
||||
* per call, has the right signature, exposes the right error shape.
|
||||
* 2. Integration tests — mount the same factory on a tiny isolated express
|
||||
* app that does fs.readFile (the exact CodeQL sink class) and prove the
|
||||
* 429 fires after the configured limit. Tight windowMs (100ms) + small
|
||||
* sleep (200ms) keeps the suite fast and resistant to CI scheduling
|
||||
* jitter; each test uses a fresh limiter so counter state never carries
|
||||
* between tests.
|
||||
* 429 fires after the configured limit. windowMs (2 000 ms) is generous
|
||||
* enough that 4 sequential requests fit inside one window even on slow
|
||||
* Windows CI runners; each test uses a fresh limiter so counter state
|
||||
* never carries between tests.
|
||||
*/
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import express, { type Express } from 'express';
|
||||
@@ -41,9 +41,9 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
// Build a fresh app + server per test so counter state never carries between
|
||||
// tests. Tight windowMs keeps the limiter responsive; the 200ms reset sleep
|
||||
// in window-rollover tests gives 2x margin even on slow CI.
|
||||
const buildApp = (limit: number, windowMs = 100): Express => {
|
||||
// tests. windowMs = 2 000 ms gives ample headroom for Windows CI where
|
||||
// sequential loopback HTTP requests can take 50–80 ms each.
|
||||
const buildApp = (limit: number, windowMs = 2000): Express => {
|
||||
const app = express();
|
||||
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
||||
app.get('/test/file', createRouteLimiter({ windowMs, limit }), async (_req, res) => {
|
||||
@@ -78,6 +78,16 @@ describe('createRouteLimiter — defaults', () => {
|
||||
// express middleware signature is (req, res, next) — 3 args.
|
||||
expect(limiter.length).toBe(3);
|
||||
});
|
||||
|
||||
// Regression guard for #1360 — createRouteLimiter must not throw
|
||||
// ERR_ERL_KEY_GEN_IPV6. The validation fires at construction time
|
||||
// (inside `rateLimit()`), so a simple `createRouteLimiter()` call is
|
||||
// the canary: if the keyGenerator references `req.ip` without using
|
||||
// `ipKeyGenerator`, the `rateLimit()` constructor throws before the
|
||||
// middleware is ever invoked.
|
||||
it('does not throw ERR_ERL_KEY_GEN_IPV6 on construction (#1360)', () => {
|
||||
expect(() => createRouteLimiter()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRouteLimiter — integration with a real route', () => {
|
||||
@@ -145,8 +155,8 @@ describe('createRouteLimiter — integration with a real route', () => {
|
||||
for (let i = 1; i <= 3; i++) await fetch(`${baseUrl}/test/file`);
|
||||
const tripped = await fetch(`${baseUrl}/test/file`);
|
||||
expect(tripped.status).toBe(429);
|
||||
// Wait for the window to roll over (100ms window + 200ms margin).
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
// Wait for the window to roll over (2 000 ms window + 200 ms margin).
|
||||
await new Promise((r) => setTimeout(r, 2200));
|
||||
const reset = await fetch(`${baseUrl}/test/file`);
|
||||
expect(reset.status).toBe(200);
|
||||
});
|
||||
@@ -242,9 +252,45 @@ describe('production routes — rate-limit middleware wiring', () => {
|
||||
expect(apiSource).toMatch(/app\.get\(SPA_FALLBACK_REGEX,\s*createRouteLimiter\(/);
|
||||
});
|
||||
|
||||
it('GET /api/health is registered (Docker healthcheck, #1147)', () => {
|
||||
expect(apiSource).toMatch(/app\.get\('\/api\/health',\s*\(_req,\s*res\)\s*=>/);
|
||||
});
|
||||
|
||||
it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => {
|
||||
expect(apiSource).toMatch(
|
||||
/app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('embed route flushes WAL via flushWAL, not inline executeQuery (#1376)', () => {
|
||||
// The embed handler must call the consolidated helper, not hand-roll
|
||||
// its own try/catch around executeQuery('CHECKPOINT').
|
||||
expect(apiSource).toMatch(/await flushWAL\(\)/);
|
||||
expect(apiSource).not.toMatch(/executeQuery\('CHECKPOINT'\)/);
|
||||
});
|
||||
});
|
||||
|
||||
// Structural guard for #1360 — validates that the validation module uses
|
||||
// `ipKeyGenerator` so IPv6 addresses are normalised to their /56 subnet.
|
||||
// Without this, each IPv6 address gets an independent counter and the
|
||||
// rate-limit is trivially bypassed. The construction-time test above
|
||||
// catches the same regression behaviourally; this source-grep test catches
|
||||
// it structurally so the failure message is immediately obvious.
|
||||
describe('validation.ts — IPv6 key normalisation (#1360)', () => {
|
||||
let validationSource: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
validationSource = await fs.readFile(
|
||||
path.join(__dirname, '..', '..', 'src', 'server', 'validation.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('imports ipKeyGenerator from express-rate-limit', () => {
|
||||
expect(validationSource).toMatch(/import.*ipKeyGenerator.*from\s+'express-rate-limit'/);
|
||||
});
|
||||
|
||||
it('keyGenerator body calls ipKeyGenerator', () => {
|
||||
expect(validationSource).toMatch(/ipKeyGenerator\(ip\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@ import { execSync } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveEmbeddingMode } from '../../src/core/embedding-mode.js';
|
||||
import {
|
||||
deriveEmbeddingMode,
|
||||
deriveEmbeddingCap,
|
||||
DEFAULT_EMBEDDING_NODE_LIMIT,
|
||||
} from '../../src/core/embedding-mode.js';
|
||||
import { getStoragePaths, saveMeta, type RepoMeta } from '../../src/storage/repo-manager.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
@@ -136,3 +140,39 @@ describe('deriveEmbeddingMode', () => {
|
||||
expect(m.preserveExistingEmbeddings).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveEmbeddingCap', () => {
|
||||
it('uses the default 50K cap when limit is undefined', () => {
|
||||
const d = deriveEmbeddingCap(10_000, undefined);
|
||||
expect(d.nodeLimit).toBe(DEFAULT_EMBEDDING_NODE_LIMIT);
|
||||
expect(d.capDisabled).toBe(false);
|
||||
expect(d.skipForCap).toBe(false);
|
||||
});
|
||||
|
||||
it('skips when node count exceeds the default cap', () => {
|
||||
const d = deriveEmbeddingCap(75_000, undefined);
|
||||
expect(d.skipForCap).toBe(true);
|
||||
expect(d.capDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not skip when node count equals the default cap (boundary)', () => {
|
||||
const d = deriveEmbeddingCap(DEFAULT_EMBEDDING_NODE_LIMIT, undefined);
|
||||
expect(d.skipForCap).toBe(false);
|
||||
});
|
||||
|
||||
it('limit=0 disables the cap regardless of node count', () => {
|
||||
const d = deriveEmbeddingCap(1_000_000, 0);
|
||||
expect(d.capDisabled).toBe(true);
|
||||
expect(d.skipForCap).toBe(false);
|
||||
expect(d.nodeLimit).toBe(0);
|
||||
});
|
||||
|
||||
it('honors a custom positive cap', () => {
|
||||
expect(deriveEmbeddingCap(99_999, 100_000).skipForCap).toBe(false);
|
||||
expect(deriveEmbeddingCap(100_001, 100_000).skipForCap).toBe(true);
|
||||
});
|
||||
|
||||
it('custom cap below default still applies', () => {
|
||||
expect(deriveEmbeddingCap(15_000, 10_000).skipForCap).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import { goScopeResolver } from '../../../../src/core/ingestion/languages/go/scope-resolver.js';
|
||||
import { populateGoRangeBindings } from '../../../../src/core/ingestion/languages/go/range-binding.js';
|
||||
import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared';
|
||||
|
||||
function parseGo(src: string, path = 'main.go'): ParsedFile {
|
||||
const p = extractParsedFile(goScopeResolver.languageProvider, src, path);
|
||||
if (p === undefined) throw new Error(`scope extraction failed for ${path}`);
|
||||
goScopeResolver.populateOwners(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function makeEmptyIndexes(): ScopeResolutionIndexes {
|
||||
return {
|
||||
bindings: new Map(),
|
||||
imports: [],
|
||||
scopeTree: { roots: [] } as any,
|
||||
methodDispatch: new Map(),
|
||||
sccs: [],
|
||||
} as ScopeResolutionIndexes;
|
||||
}
|
||||
|
||||
describe('Go range binding — null guard (#1346, #1366)', () => {
|
||||
it('does not crash on plain for loop (no range_clause)', () => {
|
||||
const src = `package main
|
||||
func main() {
|
||||
for i := 0; i < 10; i++ {
|
||||
_ = i
|
||||
}
|
||||
}`;
|
||||
const parsed = parseGo(src);
|
||||
const fileContents = new Map<string, string>([['main.go', src]]);
|
||||
// Before fix: rangeClause was undefined, checked with === null,
|
||||
// then rangeClause.namedChildren crashed.
|
||||
expect(() =>
|
||||
populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not crash on for-range without expression_list', () => {
|
||||
// Single variable range without comma: for v := range ch
|
||||
const src = `package main
|
||||
func main() {
|
||||
ch := make(chan int)
|
||||
for v := range ch {
|
||||
_ = v
|
||||
}
|
||||
}`;
|
||||
const parsed = parseGo(src);
|
||||
const fileContents = new Map<string, string>([['main.go', src]]);
|
||||
expect(() =>
|
||||
populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not crash on for-range over map with blank identifier', () => {
|
||||
const src = `package main
|
||||
func main() {
|
||||
m := map[string]int{"a": 1}
|
||||
for _, v := range m {
|
||||
_ = v
|
||||
}
|
||||
}`;
|
||||
const parsed = parseGo(src);
|
||||
const fileContents = new Map<string, string>([['main.go', src]]);
|
||||
expect(() =>
|
||||
populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not crash on for-range with type alias target', () => {
|
||||
const src = `package main
|
||||
type Users []string
|
||||
func main() {
|
||||
var users Users
|
||||
for _, u := range users {
|
||||
_ = u
|
||||
}
|
||||
}`;
|
||||
const parsed = parseGo(src);
|
||||
const fileContents = new Map<string, string>([['main.go', src]]);
|
||||
expect(() =>
|
||||
populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not crash on nested for loops mixing plain and range', () => {
|
||||
const src = `package main
|
||||
func main() {
|
||||
items := []string{"a", "b"}
|
||||
for i := 0; i < len(items); i++ {
|
||||
for _, v := range items {
|
||||
_ = v
|
||||
}
|
||||
}
|
||||
}`;
|
||||
const parsed = parseGo(src);
|
||||
const fileContents = new Map<string, string>([['main.go', src]]);
|
||||
expect(() =>
|
||||
populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -112,3 +112,55 @@ describe('Go type binding synthesis — 7 patterns', () => {
|
||||
expect(normalizeGoTypeName('List[User]')).toBe('List');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go type binding — null guard (#1346, #1366)', () => {
|
||||
it('does not crash on make(chan T) — channel_type has no matching child', () => {
|
||||
const src = 'package main\nfunc main() {\n ch := make(chan int)\n}';
|
||||
const tree = getGoParser().parse(src);
|
||||
// Before fix: .find() returned undefined, checked with !== null, crashed
|
||||
// accessing .type on undefined.
|
||||
const matches = synthesizeGoTypeBindings(tree.rootNode as any);
|
||||
// make(chan T) is not handled (V1 limitation) — should produce no crash
|
||||
// and no make-binding, not crash the pipeline.
|
||||
const makeMatch = matches.find((m) => m['@type-binding.make']);
|
||||
expect(makeMatch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not crash on new() with no type arguments', () => {
|
||||
const src = 'package main\nfunc main() {\n x := new(complex128)\n _ = x\n}';
|
||||
const tree = getGoParser().parse(src);
|
||||
// complex128 is a builtin type_identifier — this should work normally,
|
||||
// but tests the path where .find() must not return undefined unchecked.
|
||||
const matches = synthesizeGoTypeBindings(tree.rootNode as any);
|
||||
const newMatch = matches.find((m) => m['@type-binding.new']);
|
||||
expect(newMatch).toBeDefined();
|
||||
expect(newMatch?.['@type-binding.type']?.text).toBe('complex128');
|
||||
});
|
||||
|
||||
it('does not crash on make with only generic type arguments', () => {
|
||||
const src = 'package main\nfunc main() {\n ch := make(chan *User)\n}';
|
||||
const tree = getGoParser().parse(src);
|
||||
// chan *User: sliceOrMap is undefined (channel_type not in ['slice_type','map_type'])
|
||||
const matches = synthesizeGoTypeBindings(tree.rootNode as any);
|
||||
expect(matches.find((m) => m['@type-binding.make'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not crash on composite literal with embedded struct', () => {
|
||||
const src = `package main
|
||||
type Base struct{ ID int }
|
||||
type User struct{ Base }
|
||||
func main() { u := User{Base: Base{ID: 1}} }`;
|
||||
const matches = emitGoScopeCaptures(src, 'main.go');
|
||||
// Should produce captures without crashing
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not crash on short var decl with function call (no typeBinding)', () => {
|
||||
const src = 'package main\nfunc main() {\n result := DoSomething()\n}';
|
||||
const tree = getGoParser().parse(src);
|
||||
// RHS is a call_expression with no new/make — no typeBinding expected
|
||||
const matches = synthesizeGoTypeBindings(tree.rootNode as any);
|
||||
// Should not crash; may or may not produce bindings depending on the call
|
||||
expect(Array.isArray(matches)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
|
||||
.version;
|
||||
const NPX_REF = `gitnexus@${PKG_VERSION}`;
|
||||
|
||||
const execFileMock = vi.fn((...args: any[]) => {
|
||||
const callback = args.at(-1);
|
||||
@@ -63,7 +68,7 @@ describe('setupCommand codex execution', () => {
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'],
|
||||
['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
|
||||
{ shell: true },
|
||||
expect.any(Function),
|
||||
);
|
||||
@@ -78,7 +83,7 @@ describe('setupCommand codex execution', () => {
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
['mcp', 'add', 'gitnexus', '--', 'npx', '-y', 'gitnexus@latest', 'mcp'],
|
||||
['mcp', 'add', 'gitnexus', '--', 'npx', '-y', NPX_REF, 'mcp'],
|
||||
{ shell: false },
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,11 @@ import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
|
||||
.version;
|
||||
const NPX_REF = `gitnexus@${PKG_VERSION}`;
|
||||
|
||||
const execFileMock = vi.fn((...args: any[]) => {
|
||||
const callback = args.at(-1);
|
||||
@@ -232,7 +237,7 @@ describe('setupOpenCode — JSONC preservation', () => {
|
||||
|
||||
expect(config.mcp.gitnexus).toEqual({
|
||||
type: 'local',
|
||||
command: ['npx', '-y', 'gitnexus@latest', 'mcp'],
|
||||
command: ['npx', '-y', NPX_REF, 'mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
// Match what setup.ts emits — read the version from the same package.json
|
||||
// so the test never goes stale on a release bump.
|
||||
const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
|
||||
.version;
|
||||
const NPX_REF = `gitnexus@${PKG_VERSION}`;
|
||||
|
||||
const execFileMock = vi.fn((...args: any[]) => {
|
||||
const callback = args.at(-1);
|
||||
@@ -75,7 +82,7 @@ describe('setupClaudeCode', () => {
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'cmd',
|
||||
args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'],
|
||||
args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +97,7 @@ describe('setupClaudeCode', () => {
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'npx',
|
||||
args: ['-y', 'gitnexus@latest', 'mcp'],
|
||||
args: ['-y', NPX_REF, 'mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,7 +189,7 @@ describe('setupClaudeCode', () => {
|
||||
|
||||
expect(config.mcpServers.gitnexus).toEqual({
|
||||
command: 'npx',
|
||||
args: ['-y', 'gitnexus@latest', 'mcp'],
|
||||
args: ['-y', NPX_REF, 'mcp'],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user