Merge branch 'main' into feat/Desktop-app

This commit is contained in:
Sparsh 2026-05-08 10:21:41 +05:30 committed by GitHub
commit cfdcef0ec4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
140 changed files with 6630 additions and 637 deletions

View file

@ -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
}

View file

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

View file

@ -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:

View file

@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -4,6 +4,38 @@ import unusedImports from 'eslint-plugin-unused-imports';
import reactHooks from 'eslint-plugin-react-hooks';
import prettierConfig from 'eslint-config-prettier';
// Selectors that protect MCP-reachable code from corrupting the JSON-RPC
// stdio frame stream. The MCP-reachable block below uses these directly;
// the lbug-adapter file-specific block must spread them in too because
// ESLint flat config REPLACES (not merges) `no-restricted-syntax` when
// multiple matching configs target the same file. Extracting to a const
// makes the dependency mechanical instead of documentation-enforced.
const mcpStdoutWriteSelectors = [
{
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.',
},
];
export default [
// Global ignores
{
@ -59,11 +91,47 @@ export default [
},
},
// CLI package — allow console.log (it's a CLI tool)
// CLI/server packages — `console.log` IS the contract (CLI tool data output
// on stdout, e.g. `gitnexus query | jq`; server pretty-printed banners).
// Diagnostic logging (`warn`/`error`/`debug`/`info`) goes through pino like
// the rest of the codebase.
{
files: ['gitnexus/src/cli/**/*.ts', 'gitnexus/src/server/**/*.ts'],
rules: {
'no-console': 'off',
'no-console': ['error', { allow: ['log'] }],
},
},
// Forcing function for the pino migration. Severity is `error` — the
// codebase-wide migration is complete; new `console.*` in core source
// must fail lint. CLI/server are exempt above (legitimate stdout output).
// Tests, bin scripts, and the logger module itself remain exempt.
{
files: ['gitnexus/src/**/*.ts'],
ignores: ['gitnexus/src/cli/**', 'gitnexus/src/server/**', 'gitnexus/src/core/logger.ts'],
rules: {
'no-console': 'error',
},
},
// 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', ...mcpStdoutWriteSelectors],
},
},
@ -79,6 +147,34 @@ 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.
//
// ESLint flat config REPLACES (not merges) `no-restricted-syntax` when
// multiple matching configs target the same file. lbug-adapter.ts is also
// covered by the MCP-reachable block above, so we spread the shared
// mcpStdoutWriteSelectors here alongside the safeClose selectors. Without
// this, lbug-adapter would silently lose its MCP stdout-write protection.
{
files: ['gitnexus/src/core/lbug/lbug-adapter.ts'],
rules: {
'no-restricted-syntax': [
'error',
...mcpStdoutWriteSelectors,
{
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,
];

View file

@ -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": {

View file

@ -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",

View file

@ -30,6 +30,8 @@
"mnemonist": "^0.40.3",
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"tree-sitter": "^0.21.1",
"tree-sitter-c": "0.21.4",
"tree-sitter-c-sharp": "0.23.1",
@ -1579,6 +1581,12 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@ -2057,9 +2065,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"version": "25.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.1.tgz",
"integrity": "sha512-coJCN8O1q4AGyyqCAUSP06P+SrMTu18BkEj3NVAK07q6QUneD2wzj3CLv9+yP+BMeZQlMvneXqqvDe3w+xcq7g==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
@ -2380,6 +2388,15 @@
"js-tokens": "^10.0.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -2579,6 +2596,12 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@ -2683,6 +2706,15 @@
"node": ">= 8"
}
},
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2806,6 +2838,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -3018,12 +3059,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"
@ -3050,12 +3091,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/fast-copy": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@ -3416,6 +3469,12 @@
"node": ">= 0.4"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/hono": {
"version": "4.12.16",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.16.tgz",
@ -3486,9 +3545,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"
@ -3575,6 +3634,15 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
@ -4183,6 +4251,15 @@
],
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -4332,6 +4409,79 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-pretty": {
"version": "13.1.3",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
"fast-copy": "^4.0.0",
"fast-safe-stringify": "^2.1.1",
"help-me": "^5.0.0",
"joycon": "^3.1.1",
"minimist": "^1.2.6",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pump": "^3.0.0",
"secure-json-parse": "^4.0.0",
"sonic-boom": "^4.0.1",
"strip-json-comments": "^5.0.2"
},
"bin": {
"pino-pretty": "bin.js"
}
},
"node_modules/pino-pretty/node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@ -4376,6 +4526,22 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.5.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
@ -4413,6 +4579,16 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@ -4428,6 +4604,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -4483,6 +4665,15 @@
"rc": "cli.js"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -4591,12 +4782,37 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -4828,6 +5044,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -4838,6 +5063,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@ -4925,6 +5159,18 @@
"node": ">=18"
}
},
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View file

@ -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",
@ -72,6 +73,8 @@
"mnemonist": "^0.40.3",
"onnxruntime-node": "^1.24.0",
"pandemonium": "^2.4.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"tree-sitter": "^0.21.1",
"tree-sitter-c": "0.21.4",
"tree-sitter-c-sharp": "0.23.1",

View file

@ -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');

View file

@ -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');

View file

@ -10,6 +10,7 @@ import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { type GeneratedSkillInfo } from './skill-gen.js';
import { logger } from '../core/logger.js';
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
@ -293,7 +294,7 @@ Use GitNexus tools to accomplish this task.
installedSkills.push(skill.name);
} catch (err) {
// Skip on error, don't fail the whole process
console.warn(`Warning: Could not install skill ${skill.name}:`, err);
logger.warn({ err }, `Warning: Could not install skill ${skill.name}:`);
}
}

View file

@ -23,7 +23,10 @@ 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';
import { cliError } from './cli-message.js';
// Capture stderr.write at module load BEFORE anything (LadybugDB native
// init, progress bar, console redirection) can monkey-patch it. The
@ -95,7 +98,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
@ -158,7 +168,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
if (options?.workerTimeout) {
const workerTimeoutSeconds = Number(options.workerTimeout);
if (!Number.isFinite(workerTimeoutSeconds) || workerTimeoutSeconds < 1) {
console.error(' --worker-timeout must be at least 1 second.\n');
cliError(' --worker-timeout must be at least 1 second.\n');
process.exitCode = 1;
return;
}
@ -167,6 +177,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) {
cliError(
` --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,
@ -175,7 +204,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
if (value === undefined) return true;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
console.error(` ${optionName} must be a positive integer.\n`);
cliError(` ${optionName} must be a positive integer.\n`);
process.exitCode = 1;
return false;
}
@ -206,7 +235,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
if (options?.embeddingDevice) {
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
if (!allowed.has(options.embeddingDevice)) {
console.error(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
cliError(' --embedding-device must be one of: auto, cpu, dml, cuda, wasm.\n');
process.exitCode = 1;
return;
}
@ -247,6 +276,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.
@ -278,7 +331,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
bar.start(100, 0, { phase: 'Initializing...' });
// Graceful SIGINT handling
// Graceful SIGINT handling. Pino's default destination is `sync: false`
// (buffered) — flush before exit so in-flight records reach stderr.
// See `gitnexus/src/core/logger.ts:flushLoggerSync`.
let aborted = false;
const sigintHandler = () => {
if (aborted) process.exit(1);
@ -287,13 +342,23 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
console.log('\n Interrupted — cleaning up...');
closeLbug()
.catch(() => {})
.finally(() => process.exit(130));
.finally(async () => {
const { flushLoggerSync } = await import('../core/logger.js');
flushLoggerSync();
process.exit(130);
});
};
process.on('SIGINT', sigintHandler);
// Route console output through bar.log() to prevent progress bar corruption
// Route console output through bar.log() to prevent progress bar corruption.
// This is a deliberate UI pattern (not a logging concern): analyze runs a
// long-lived progress bar on stdout; any concurrent console.* write would
// overwrite the bar mid-render. We capture originals, swap to barLog for
// the lifetime of the run, and restore on completion/error/SIGINT.
const origLog = console.log.bind(console);
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
const origWarn = console.warn.bind(console);
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
const origError = console.error.bind(console);
let barCurrentValue = 0;
const barLog = (...args: any[]) => {
@ -302,7 +367,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
bar.update(barCurrentValue);
};
console.log = barLog;
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
console.warn = barLog;
// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX
console.error = barLog;
// Track elapsed time per phase
@ -338,7 +405,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,
@ -367,7 +435,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
clearInterval(elapsedTimer);
process.removeListener('SIGINT', sigintHandler);
console.log = origLog;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.warn = origWarn;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.error = origError;
bar.stop();
console.log(' Already up to date\n');
@ -440,7 +510,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
process.removeListener('SIGINT', sigintHandler);
console.log = origLog;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.warn = origWarn;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.error = origError;
bar.update(100, { phase: 'Done' });
@ -465,7 +537,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
clearInterval(elapsedTimer);
process.removeListener('SIGINT', sigintHandler);
console.log = origLog;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.warn = origWarn;
// eslint-disable-next-line no-console -- restoring after intentional progress-bar routing
console.error = origError;
bar.stop();
@ -474,14 +548,14 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// Registry name-collision from --name (#829) — surface as an
// actionable error rather than a generic stack-trace.
if (err instanceof RegistryNameCollisionError) {
console.error(`\n Registry name collision:\n`);
console.error(` "${err.registryName}" is already used by "${err.existingPath}".\n`);
console.error(` Options:`);
console.error(` • Pick a different alias: gitnexus analyze --name <alias>`);
console.error(
` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)`,
cliError(
`\n Registry name collision:\n` +
` "${err.registryName}" is already used by "${err.existingPath}".\n\n` +
` Options:\n` +
` • Pick a different alias: gitnexus analyze --name <alias>\n` +
` • Allow the duplicate: gitnexus analyze --allow-duplicate-name (leaves "-r ${err.registryName}" ambiguous)\n`,
{ registryName: err.registryName, existingPath: err.existingPath },
);
console.error('');
process.exitCode = 1;
return;
}
@ -521,34 +595,40 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
msg.includes('heap out of memory') ||
msg.includes('JavaScript heap')
) {
console.error(' This error typically occurs on very large repositories.');
console.error(' Suggestions:');
console.error(' 1. Add large vendored/generated directories to .gitnexusignore');
console.error(' 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"');
console.error(' 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"');
console.error('');
cliError(
` This error typically occurs on very large repositories.\n` +
` Suggestions:\n` +
` 1. Add large vendored/generated directories to .gitnexusignore\n` +
` 2. Increase Node.js heap: NODE_OPTIONS="--max-old-space-size=16384"\n` +
` 3. Increase stack size: NODE_OPTIONS="--stack-size=4096"\n`,
{ recoveryHint: 'large-repo' },
);
} else if (msg.includes('ERESOLVE') || msg.includes('Could not resolve dependency')) {
// Note: the original arborist "Cannot destructure property 'package' of
// 'node.target'" crash happens inside npm *before* gitnexus code runs,
// so it can't be caught here. This branch handles dependency-resolution
// errors that surface at runtime (e.g. dynamic require failures).
console.error(' This looks like an npm dependency resolution issue.');
console.error(' Suggestions:');
console.error(' 1. Clear the npm cache: npm cache clean --force');
console.error(' 2. Update npm: npm install -g npm@latest');
console.error(' 3. Reinstall gitnexus: npm install -g gitnexus@latest');
console.error(' 4. Or try npx directly: npx gitnexus@latest analyze');
console.error('');
cliError(
` This looks like an npm dependency resolution issue.\n` +
` Suggestions:\n` +
` 1. Clear the npm cache: npm cache clean --force\n` +
` 2. Update npm: npm install -g npm@latest\n` +
` 3. Reinstall gitnexus: npm install -g gitnexus@latest\n` +
` 4. Or try npx directly: npx gitnexus@latest analyze\n`,
{ recoveryHint: 'npm-resolution' },
);
} else if (
msg.includes('MODULE_NOT_FOUND') ||
msg.includes('Cannot find module') ||
msg.includes('ERR_MODULE_NOT_FOUND')
) {
console.error(' A required module could not be loaded. The installation may be corrupt.');
console.error(' Suggestions:');
console.error(' 1. Reinstall: npm install -g gitnexus@latest');
console.error(' 2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze');
console.error('');
cliError(
` A required module could not be loaded. The installation may be corrupt.\n` +
` Suggestions:\n` +
` 1. Reinstall: npm install -g gitnexus@latest\n` +
` 2. Clear cache: npm cache clean --force && npx gitnexus@latest analyze\n`,
{ recoveryHint: 'module-not-found' },
);
}
process.exitCode = 1;

View file

@ -6,6 +6,7 @@
*/
import fs from 'fs/promises';
import { logger } from '../core/logger.js';
import {
findRepo,
unregisterRepo,
@ -45,7 +46,7 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
console.error(`Refusing to clean ${entry.name}: ${err.message}`);
logger.error(`Refusing to clean ${entry.name}: ${err.message}`);
continue;
}
throw err;
@ -56,7 +57,7 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
await unregisterRepo(entry.path);
console.log(`Deleted: ${entry.name} (${entry.storagePath})`);
} catch (err) {
console.error(`Failed to delete ${entry.name}:`, err);
logger.error({ err }, `Failed to delete ${entry.name}:`);
}
}
return;
@ -85,6 +86,6 @@ export const cleanCommand = async (options?: { force?: boolean; all?: boolean })
await unregisterRepo(repo.repoPath);
console.log(`Deleted: ${repo.storagePath}`);
} catch (err) {
console.error('Failed to delete:', err);
logger.error({ err }, 'Failed to delete:');
}
};

View file

@ -0,0 +1,65 @@
/**
* CLI message helpers for user-facing banners, error guidance, and
* recovery hints emitted by `gitnexus` subcommands.
*
* These functions write **plain text** directly to `process.stderr` AND
* tee a structured pino record through the singleton `logger`. Plain text
* preserves the human-readable contract for users running `gitnexus`
* interactively, redirecting to a file, or piping to `cat`/`grep`. The
* structured tee keeps log aggregators happy.
*
* **Use these for:**
* - User-facing banners ("Server listening on http://...:N")
* - Validation errors ("--worker-timeout must be at least 1 second")
* - Recovery hints ("Suggestions: 1. Clear the npm cache, 2. ...")
* - One-line user notices ("No indexed repositories found.")
*
* **Do NOT use these for:**
* - Internal diagnostics (worker progress, retry counts, telemetry)
* use `logger.info`/`warn`/`error` directly. Internal logs only
* need structured fields, not double-output to stderr.
* - High-volume hot paths every `cliMessage` call writes twice (raw
* + structured). Acceptable for user-facing messages, wasteful for
* ingestion pipeline events.
*
* Design note: stderr is the right channel even for non-error messages
* because GitNexus CLI tools (`query`, `cypher`, `impact`) emit JSON
* data on stdout for piping (`gitnexus query | jq`). User banners on
* stdout would corrupt that pipeline.
*/
import { logger } from '../core/logger.js';
function writeStderr(msg: string): void {
// Direct write — bypassing `console.*` so it cannot be intercepted by
// progress-bar redirection (see `cli/analyze.ts:barLog`) or other
// routing. The structured tee below still goes through the logger so
// log aggregation works either way.
process.stderr.write(msg.endsWith('\n') ? msg : msg + '\n');
}
/**
* User-facing informational message. Use for banners, listening URLs,
* and any message the user expects to read in plain text.
*/
export function cliInfo(msg: string, fields?: Record<string, unknown>): void {
writeStderr(msg);
logger.info(fields ?? {}, msg);
}
/**
* User-facing warning. Operator-actionable but non-fatal `cliWarn`
* indicates the command can still proceed in some form.
*/
export function cliWarn(msg: string, fields?: Record<string, unknown>): void {
writeStderr(msg);
logger.warn(fields ?? {}, msg);
}
/**
* User-facing error. Indicates the command cannot proceed; usually
* paired with a non-zero exit code at the call site.
*/
export function cliError(msg: string, fields?: Record<string, unknown>): void {
writeStderr(msg);
logger.error(fields ?? {}, msg);
}

View file

@ -27,6 +27,8 @@
import http from 'http';
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { logger } from '../core/logger.js';
import { cliInfo, cliWarn } from './cli-message.js';
export interface EvalServerOptions {
port?: string;
@ -332,13 +334,19 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
const ok = await backend.init();
if (!ok) {
console.error('GitNexus eval-server: No indexed repositories found. Run: gitnexus analyze');
// Operator-actionable but the server cannot start; warn-level so log
// aggregators don't trip error alerts on a configuration miss. Use
// cliWarn so the diagnostic reaches stderr synchronously before
// process.exit() — direct logger.warn would be lost to the buffered
// pino destination on hard exit (skips beforeExit flush).
cliWarn('GitNexus eval-server: No indexed repositories found. Run: gitnexus analyze');
process.exit(1);
}
const repos = await backend.listRepos();
console.error(
`GitNexus eval-server: ${repos.length} repo(s) loaded: ${repos.map((r) => r.name).join(', ')}`,
logger.info(
{ repoCount: repos.length, repos: repos.map((r) => r.name) },
'GitNexus eval-server: repos loaded',
);
let idleTimer: ReturnType<typeof setTimeout> | null = null;
@ -347,7 +355,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
if (idleTimeoutSec <= 0) return;
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(async () => {
console.error('GitNexus eval-server: Idle timeout reached, shutting down');
logger.info({ idleTimeoutSec }, 'GitNexus eval-server: idle timeout reached, shutting down');
await backend.disconnect();
process.exit(0);
}, idleTimeoutSec * 1000);
@ -419,16 +427,34 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
});
server.listen(port, '127.0.0.1', () => {
console.error(`GitNexus eval-server: listening on http://127.0.0.1:${port}`);
console.error(` POST /tool/query — search execution flows`);
console.error(` POST /tool/context — 360-degree symbol view`);
console.error(` POST /tool/impact — blast radius analysis`);
console.error(` POST /tool/cypher — raw Cypher query`);
console.error(` GET /health — health check`);
console.error(` POST /shutdown — graceful shutdown`);
// Plain-text banner for the human watching stderr; structured record
// for log aggregation (split into two so the user sees a real banner
// not `{"level":30,"msg":"...","port":4747,"endpoints":[...]}`).
const bannerLines = [
`GitNexus eval-server: listening on http://127.0.0.1:${port}`,
` POST /tool/query — search execution flows`,
` POST /tool/context — 360-degree symbol view`,
` POST /tool/impact — blast radius analysis`,
` POST /tool/cypher — raw Cypher query`,
` GET /health — health check`,
` POST /shutdown — graceful shutdown`,
];
if (idleTimeoutSec > 0) {
console.error(` Auto-shutdown after ${idleTimeoutSec}s idle`);
bannerLines.push(` Auto-shutdown after ${idleTimeoutSec}s idle`);
}
cliInfo(bannerLines.join('\n'), {
port,
host: '127.0.0.1',
idleTimeoutSec: idleTimeoutSec > 0 ? idleTimeoutSec : undefined,
endpoints: [
'POST /tool/query',
'POST /tool/context',
'POST /tool/impact',
'POST /tool/cypher',
'GET /health',
'POST /shutdown',
],
});
try {
// Use fd 1 directly — LadybugDB captures process.stdout (#324)
writeSync(1, `GITNEXUS_EVAL_SERVER_READY:${port}\n`);
@ -440,7 +466,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
resetIdleTimer();
const shutdown = async () => {
console.error('GitNexus eval-server: shutting down...');
logger.info('GitNexus eval-server: shutting down...');
await backend.disconnect();
server.close();
process.exit(0);

View file

@ -1,6 +1,7 @@
// gitnexus/src/cli/group.ts
import { createRequire } from 'node:module';
import type { Command } from 'commander';
import { logger } from '../core/logger.js';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
@ -51,7 +52,7 @@ export function registerGroupCommands(program: Command): void {
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
if (!(repoPath in config.repos)) {
console.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
logger.error(`Repo path "${repoPath}" not found in group "${groupName}"`);
process.exitCode = 1;
return;
}
@ -239,7 +240,7 @@ export function registerGroupCommands(program: Command): void {
const raw = await backend.getGroupService().groupImpact(payload);
if (raw && typeof raw === 'object' && 'error' in raw) {
console.error(String((raw as { error: string }).error));
logger.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}
@ -333,7 +334,7 @@ export function registerGroupCommands(program: Command): void {
});
if (raw && typeof raw === 'object' && 'error' in raw) {
console.error(String((raw as { error: string }).error));
logger.error(String((raw as { error: string }).error));
process.exitCode = 1;
return;
}

View file

@ -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` ' +

View file

@ -4,23 +4,61 @@
* 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.
// Dynamically import heavy backend modules AND the pino logger AFTER
// the sentinel installs. The logger is dynamic-imported (rather than
// static) to preserve the leaf-only static-import closure documented at
// the top of this file — `core/logger.js` itself doesn't write to
// stdout at module init, but transitive deps (pino, pino-pretty, the
// worker-thread transport) could in theory, and the import-closure
// regression test enforces the leaf invariant.
const [{ startMCPServer }, { LocalBackend }, { logger }] = await Promise.all([
import('../mcp/server.js'),
import('../mcp/local/local-backend.js'),
import('../core/logger.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,
@ -30,12 +68,15 @@ export const mcpCommand = async () => {
const repos = await backend.listRepos();
if (repos.length === 0) {
console.error(
// Operator-actionable but the server still starts and serves; warn-level,
// not error. Tools will discover newly-analyzed repos via lazy refresh.
logger.warn(
'GitNexus: No indexed repos yet. Run `gitnexus analyze` in a git repo — the server will pick it up automatically.',
);
} else {
console.error(
`GitNexus: MCP server starting with ${repos.length} repo(s): ${repos.map((r) => r.name).join(', ')}`,
logger.info(
{ repoCount: repos.length, repos: repos.map((r) => r.name) },
'GitNexus: MCP server starting',
);
}

View file

@ -0,0 +1,114 @@
/**
* 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';
import { cliWarn } from './cli-message.js';
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. cliWarn
// writes plain text to stderr AND tees a structured logger.warn
// record; the merged repo-wide ESLint pino-migration rule forbids
// direct `console.error` in CLI code (only `console.log` is allowed
// there for tool-data stdout output).
cliWarn(
`GitNexus: optional grammar "${g.name}" is installed but failed to load (${msg.slice(0, 200)}). ${g.extensions.join('/')} files will not be parsed.`,
{ grammar: g.name, extensions: g.extensions, error: msg },
);
}
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}]` : '';
// Hoist the optional set into a local so the closure below can narrow
// its type; references to `opts?.relevantExtensions` inside `.some()`
// lose the outer null-check narrowing and require a non-null assertion.
const relevantExtensions = opts?.relevantExtensions;
for (const g of missing) {
if (relevantExtensions && !g.extensions.some((e) => relevantExtensions.has(e))) {
continue;
}
cliWarn(
`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.`,
{ grammar: g.name, extensions: g.extensions, context: opts?.context },
);
}
}

View file

@ -27,6 +27,8 @@
*/
import fs from 'fs/promises';
import { logger } from '../core/logger.js';
import { cliError } from './cli-message.js';
import {
readRegistry,
resolveRegistryEntry,
@ -51,14 +53,14 @@ export const removeCommand = async (target: string, options?: { force?: boolean
// Idempotent: missing target is a no-op warning, not an error.
// The `availableNames` hint comes from the error itself so users
// can see what they might have meant.
console.warn(`Nothing to remove: ${err.message}`);
logger.warn(`Nothing to remove: ${err.message}`);
return;
}
if (err instanceof RegistryAmbiguousTargetError) {
// Duplicate aliases are allowed via --allow-duplicate-name (#829);
// refuse to guess which one the user meant — surface the full list
// and exit non-zero so scripts don't silently pick the wrong repo.
console.error(`Error: ${err.message}`);
cliError(`Error: ${err.message}`);
process.exit(1);
}
throw err;
@ -86,7 +88,7 @@ export const removeCommand = async (target: string, options?: { force?: boolean
assertSafeStoragePath(entry);
} catch (err) {
if (err instanceof UnsafeStoragePathError) {
console.error(`Error: ${err.message}`);
cliError(`Error: ${err.message}`);
process.exit(1);
}
throw err;
@ -104,7 +106,8 @@ export const removeCommand = async (target: string, options?: { force?: boolean
console.log(` Path: ${entry.path}`);
console.log(` Storage: ${entry.storagePath}`);
} catch (err) {
console.error(`Failed to remove ${entry.name}:`, err);
const msg = err instanceof Error ? err.message : String(err);
cliError(`Failed to remove ${entry.name}: ${msg}`, { err });
process.exit(1);
}
};

View file

@ -1,14 +1,26 @@
import { createServer } from '../server/api.js';
import { logger, flushLoggerSync } from '../core/logger.js';
import { cliError } from './cli-message.js';
// Catch anything that would cause a silent exit
// Catch anything that would cause a silent exit. Pino v10's default
// destination is `sync: false` (SonicBoom buffered) — call
// `flushLoggerSync()` between the log and `process.exit(1)` so the crash
// record is not lost to the unflushed buffer. Worker-thread transports
// (pino-pretty under TTY) handle their own flush on process exit in v10,
// so no separate `pino.final` integration is needed (the API was removed
// in v10 because the transport architecture made it unnecessary).
//
// We pass the Error itself in `{ err }` so pino's built-in err serializer
// captures `type`, `message`, and `stack` as structured fields.
process.on('uncaughtException', (err) => {
console.error('\n[gitnexus serve] Uncaught exception:', err.message);
if (process.env.DEBUG) console.error(err.stack);
logger.error({ err }, '[gitnexus serve] Uncaught exception');
flushLoggerSync();
process.exit(1);
});
process.on('unhandledRejection', (reason: any) => {
console.error('\n[gitnexus serve] Unhandled rejection:', reason?.message || reason);
if (process.env.DEBUG) console.error(reason?.stack);
process.on('unhandledRejection', (reason) => {
const err = reason instanceof Error ? reason : new Error(String(reason));
logger.error({ err }, '[gitnexus serve] Unhandled rejection');
flushLoggerSync();
process.exit(1);
});
@ -22,16 +34,26 @@ export const serveCommand = async (options?: { port?: string; host?: string }) =
try {
await createServer(port, host);
} catch (err: any) {
console.error(`\nFailed to start GitNexus server:\n`);
console.error(` ${err.message || err}\n`);
if (err.code === 'EADDRINUSE') {
console.error(` Port ${port} is already in use. Either:`);
console.error(` 1. Stop the other process using port ${port}`);
console.error(` 2. Use a different port: gitnexus serve --port 4748\n`);
cliError(
`\nFailed to start GitNexus server:\n` +
` ${err.message || err}\n\n` +
` Port ${port} is already in use. Either:\n` +
` 1. Stop the other process using port ${port}\n` +
` 2. Use a different port: gitnexus serve --port 4748\n`,
{ code: err.code, port, host },
);
} else {
cliError(`\nFailed to start GitNexus server:\n ${err.message || err}\n`, {
code: err.code,
port,
host,
});
}
if (err.stack && process.env.DEBUG) {
console.error(err.stack);
logger.debug({ stack: err.stack }, 'serve start error stack');
}
flushLoggerSync();
process.exit(1);
}
};

View file

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

View file

@ -17,6 +17,7 @@
import { writeSync } from 'node:fs';
import { LocalBackend } from '../mcp/local/local-backend.js';
import { cliError } from './cli-message.js';
let _backend: LocalBackend | null = null;
@ -25,7 +26,7 @@ async function getBackend(): Promise<LocalBackend> {
_backend = new LocalBackend();
const ok = await _backend.init();
if (!ok) {
console.error('GitNexus: No indexed repositories found. Run: gitnexus analyze');
cliError('GitNexus: No indexed repositories found. Run: gitnexus analyze');
process.exit(1);
}
return _backend;
@ -67,7 +68,7 @@ export async function queryCommand(
},
): Promise<void> {
if (!queryText?.trim()) {
console.error('Usage: gitnexus query <search_query>');
cliError('Usage: gitnexus query <search_query>');
process.exit(1);
}
@ -93,7 +94,7 @@ export async function contextCommand(
},
): Promise<void> {
if (!name?.trim() && !options?.uid) {
console.error('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
cliError('Usage: gitnexus context <symbol_name> [--uid <uid>] [--file <path>]');
process.exit(1);
}
@ -118,7 +119,7 @@ export async function impactCommand(
},
): Promise<void> {
if (!target?.trim()) {
console.error('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
cliError('Usage: gitnexus impact <symbol_name> [--direction upstream|downstream]');
process.exit(1);
}
@ -153,7 +154,7 @@ export async function cypherCommand(
},
): Promise<void> {
if (!query?.trim()) {
console.error('Usage: gitnexus cypher <cypher_query>');
cliError('Usage: gitnexus cypher <cypher_query>');
process.exit(1);
}

View file

@ -19,6 +19,7 @@ import {
import { WikiGenerator, type WikiOptions } from '../core/wiki/generator.js';
import { resolveLLMConfig, type LLMProvider } from '../core/wiki/llm-client.js';
import { detectCursorCLI } from '../core/wiki/cursor-client.js';
import { logger } from '../core/logger.js';
export interface WikiCommandOptions {
force?: boolean;
@ -583,7 +584,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
} else {
console.log(`\n Error: ${err.message}\n`);
if (process.env.GITNEXUS_VERBOSE) {
console.error(err);
logger.error({ err }, 'wiki command failed');
}
}
process.exitCode = 1;

View file

@ -2,6 +2,7 @@ import ignore, { type Ignore } from 'ignore';
import fs from 'fs/promises';
import nodePath from 'path';
import type { Path } from 'path-scurry';
import { logger } from '../core/logger.js';
const DEFAULT_IGNORE_LIST = new Set([
// Version Control
@ -365,7 +366,7 @@ export const loadIgnoreRules = async (
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') {
console.warn(` Warning: could not read ${filename}: ${(err as Error).message}`);
logger.warn(` Warning: could not read ${filename}: ${(err as Error).message}`);
}
}
}

View file

@ -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,

View file

@ -23,6 +23,7 @@ import { DEFAULT_EMBEDDING_CONFIG, type EmbeddingConfig, type ModelProgress } fr
import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
import { resolveEmbeddingConfig } from './config.js';
import { applyHfEnvOverrides } from './hf-env.js';
import { logger } from '../logger.js';
/**
* Check whether the onnxruntime-node package that @huggingface/transformers
@ -166,7 +167,7 @@ export const initEmbedder = async (
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
console.log(`🧠 Loading embedding model: ${finalConfig.modelId}`);
logger.info(`🧠 Loading embedding model: ${finalConfig.modelId}`);
}
const progressCallback = onProgress
@ -192,13 +193,13 @@ export const initEmbedder = async (
for (const device of devicesToTry) {
try {
if (isDev && device === 'dml') {
console.log('🔧 Trying DirectML (DirectX12) GPU backend...');
logger.info('🔧 Trying DirectML (DirectX12) GPU backend...');
} else if (isDev && device === 'cuda') {
console.log('🔧 Trying CUDA GPU backend...');
logger.info('🔧 Trying CUDA GPU backend...');
} else if (isDev && device === 'cpu') {
console.log('🔧 Using CPU backend...');
logger.info('🔧 Using CPU backend...');
} else if (isDev && device === 'wasm') {
console.log('🔧 Using WASM backend (slower)...');
logger.info('🔧 Using WASM backend (slower)...');
}
embedderInstance = await (pipeline as any)('feature-extraction', finalConfig.modelId, {
@ -221,15 +222,15 @@ export const initEmbedder = async (
: device === 'cuda'
? 'GPU (CUDA)'
: device.toUpperCase();
console.log(`✅ Using ${label} backend`);
console.log('✅ Embedding model loaded successfully');
logger.info(`✅ Using ${label} backend`);
logger.info('✅ 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...`);
logger.info(`⚠️ ${gpuType} not available, falling back to CPU...`);
}
// Continue to next device in list
if (device === devicesToTry[devicesToTry.length - 1]) {

View file

@ -44,6 +44,7 @@ import {
} from '../lbug/schema.js';
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
import { getExactScanLimit } from '../platform/capabilities.js';
import { logger } from '../logger.js';
const isDev = process.env.NODE_ENV === 'development';
@ -157,7 +158,7 @@ const queryEmbeddableNodes = async (
}
} catch (error) {
if (isDev) {
console.warn(`Query for ${label} nodes failed:`, error);
logger.warn({ error }, `Query for ${label} nodes failed:`);
}
}
}
@ -212,7 +213,7 @@ const createVectorIndex = async (
return true;
} catch (error) {
if (isDev) {
console.warn('Vector index creation warning:', error);
logger.warn({ error }, 'Vector index creation warning:');
}
return false;
}
@ -256,7 +257,9 @@ export const runEmbeddingPipeline = async (
try {
const vectorAvailable = await ensureVectorExtensionAvailable();
if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage);
if (!vectorAvailable && isDev) {
logger.warn(vectorUnavailableMessage);
}
// Phase 1: Load embedding model
onProgress({
@ -283,7 +286,7 @@ export const runEmbeddingPipeline = async (
});
if (isDev) {
console.log('🔍 Querying embeddable nodes...');
logger.info('🔍 Querying embeddable nodes...');
}
// Phase 2: Query embeddable nodes
@ -325,7 +328,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`);
logger.info(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`);
}
try {
await executeWithReusedStatement(
@ -346,7 +349,7 @@ export const runEmbeddingPipeline = async (
}
if (isDev) {
console.log(
logger.info(
`📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`,
);
}
@ -355,7 +358,7 @@ export const runEmbeddingPipeline = async (
const totalNodes = nodes.length;
if (isDev) {
console.log(`📊 Found ${totalNodes} embeddable nodes`);
logger.info(`📊 Found ${totalNodes} embeddable nodes`);
}
if (totalNodes === 0) {
@ -442,9 +445,9 @@ export const runEmbeddingPipeline = async (
);
} catch (chunkErr) {
if (isDev) {
console.warn(
logger.warn(
{ chunkErr },
`⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`,
chunkErr,
);
}
chunks = characterChunk(node.content, startLine, endLine, chunkSize, overlap);
@ -482,9 +485,9 @@ export const runEmbeddingPipeline = async (
try {
embeddings = await embedBatch(subTexts);
} catch (embedErr) {
console.error(
logger.error(
{ embedErr },
`❌ embedBatch failed for ${subTexts.length} texts (first: "${subTexts[0]?.substring(0, 80)}..."):`,
embedErr,
);
throw embedErr;
}
@ -520,7 +523,7 @@ export const runEmbeddingPipeline = async (
});
if (isDev) {
console.log('📇 Creating vector index...');
logger.info('📇 Creating vector index...');
}
const vectorIndexReady = await createVectorIndex(executeQuery);
@ -533,7 +536,7 @@ export const runEmbeddingPipeline = async (
});
if (isDev) {
console.log(
logger.info(
`✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`,
);
}
@ -547,7 +550,7 @@ export const runEmbeddingPipeline = async (
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (isDev) {
console.error('❌ Embedding pipeline error:', error);
logger.error({ error }, '❌ Embedding pipeline error:');
}
onProgress({

View file

@ -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';
@ -11,6 +11,9 @@ import {
type LbugConnectionHandle,
} from '../lbug/lbug-config.js';
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
import { createLogger } from '../logger.js';
const bridgeLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE' });
/**
* Sidecar files that LadybugDB creates next to a `bridge.lbug` file.
@ -24,7 +27,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 +44,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 +299,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 +369,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 +389,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.
//
@ -681,14 +705,10 @@ export async function openBridgeDbReadOnly(groupDir: string): Promise<BridgeHand
await new Promise((r) => setTimeout(r, delay));
}
}
if (process.env.GITNEXUS_DEBUG_BRIDGE) {
console.warn(
`[bridge-db] openBridgeDbReadOnly(${groupDir}) gave up after ` +
`${LBUG_OPEN_RETRY_ATTEMPTS} attempts: ${
lastErr instanceof Error ? lastErr.message : String(lastErr)
}`,
);
}
bridgeLogger.debug(
{ groupDir, err: lastErr, attempts: LBUG_OPEN_RETRY_ATTEMPTS },
'openBridgeDbReadOnly gave up',
);
return null;
}

View file

@ -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,

View file

@ -4,6 +4,7 @@ import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
interface ElixirAppMeta {
appName: string;
modulePrefix: string;
@ -202,7 +203,7 @@ export async function extractElixirWorkspaceLinks(
};
const existing = appsByName.get(manifest.appName);
if (existing) {
console.warn(
logger.warn(
`[elixir-workspace-extractor] duplicate app "${manifest.appName}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;

View file

@ -4,6 +4,7 @@ import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
interface GoModuleMeta {
modulePath: string;
groupPath: string;
@ -211,7 +212,7 @@ export async function extractGoWorkspaceLinks(
};
const existing = modulesByPath.get(manifest.modulePath);
if (existing) {
console.warn(
logger.warn(
`[go-workspace-extractor] duplicate module "${manifest.modulePath}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;

View file

@ -5,6 +5,7 @@ import { createIgnoreFilter } from '../../../config/ignore-service.js';
import type { ContractExtractor, CypherExecutor } from '../contract-extractor.js';
import type { ExtractedContract, RepoHandle } from '../types.js';
import { readSafe } from './fs-utils.js';
import { logger } from '../../logger.js';
import {
GRPC_SCAN_GLOB,
getPluginForFile,
@ -344,7 +345,7 @@ export function resolveProtoConflict(
// services under a fabricated package-qualified contract id.
if (winners.length !== 1) {
const paths = candidates.map((c) => c.protoPath).join(', ');
console.warn(
logger.warn(
`[grpc-extractor] Ambiguous proto resolution for service "${serviceName}" from ${sourceFilePath}: ${winners.length} candidates tied at score ${maxScore} among [${paths}] — skipping canonical contract`,
);
return null;

View file

@ -4,6 +4,7 @@ import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
interface JavaProjectMeta {
groupId: string;
artifactId: string;
@ -213,7 +214,7 @@ export async function extractJavaWorkspaceLinks(
};
const existing = projectsByKey.get(key);
if (existing) {
console.warn(
logger.warn(
`[java-workspace-extractor] duplicate artifact "${key}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;

View file

@ -1,6 +1,7 @@
import type { ContractType, CrossLink, GroupManifestLink, StoredContract } from '../types.js';
import type { CypherExecutor } from '../contract-extractor.js';
import { logger } from '../../logger.js';
export interface ManifestExtractResult {
contracts: StoredContract[];
crossLinks: CrossLink[];
@ -177,7 +178,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 +226,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(
@ -298,7 +304,7 @@ export class ManifestExtractor {
// fail the whole manifest extraction. Unresolved contracts still
// get a synthetic symbolUid below, so cross-impact can proceed.
const message = err instanceof Error ? err.message : String(err);
console.warn(
logger.warn(
`[manifest-extractor] resolveSymbol failed for ${link.type}:${link.contract} ` +
`in ${repoPathKey}: ${message}`,
);
@ -344,6 +350,8 @@ export class ManifestExtractor {
}
case 'grpc':
return `grpc::${contract}`;
case 'thrift':
return `thrift::${contract}`;
case 'topic':
return `topic::${contract}`;
case 'lib':

View file

@ -4,6 +4,7 @@ import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
interface PackageMeta {
name: string;
groupPath: string;
@ -205,7 +206,7 @@ export async function extractNodeWorkspaceLinks(
};
const existing = packagesByName.get(manifest.name);
if (existing) {
console.warn(
logger.warn(
`[node-workspace-extractor] duplicate package name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;

View file

@ -4,6 +4,7 @@ import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
interface PythonPackageMeta {
name: string;
importName: string;
@ -204,7 +205,7 @@ export async function extractPythonWorkspaceLinks(
};
const existing = packagesByImportName.get(manifest.importName);
if (existing) {
console.warn(
logger.warn(
`[python-workspace-extractor] duplicate package "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;

View file

@ -5,6 +5,7 @@ import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath } from '../../../config/ignore-service.js';
import { loadIgnoreRules } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
/**
* Discover cross-crate contracts in a Rust workspace by reading each
* member's `Cargo.toml` dependencies and scanning source files for
@ -224,7 +225,7 @@ export async function extractRustWorkspaceLinks(
};
const existing = cratesByName.get(manifest.name);
if (existing) {
console.warn(
logger.warn(
`[rust-workspace-extractor] duplicate crate name "${manifest.name}" in "${groupPath}" and "${existing.groupPath}" — skipping "${groupPath}"`,
);
continue;

View file

@ -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());
}
}

View file

@ -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];
}

View file

@ -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;
},
};

View file

@ -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[];
}

View file

@ -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}`);
});

View file

@ -14,6 +14,7 @@ import {
} from './group-path-utils.js';
import { getDefaultGitnexusDir, getGroupDir, listGroups, readContractRegistry } from './storage.js';
import { syncGroup } from './sync.js';
import { logger } from '../logger.js';
import type {
ContractRegistry,
CrossLink,
@ -170,11 +171,11 @@ async function loadContractRegistryResilient(
contracts.push(row);
} else {
skippedCorrupt++;
console.warn('[group] skipping corrupt contract row in contracts.json');
logger.warn('[group] skipping corrupt contract row in contracts.json');
}
} catch {
skippedCorrupt++;
console.warn('[group] skipping corrupt contract row in contracts.json');
logger.warn('[group] skipping corrupt contract row in contracts.json');
}
}
}
@ -187,11 +188,11 @@ async function loadContractRegistryResilient(
crossLinks.push(row);
} else {
skippedCorrupt++;
console.warn('[group] skipping corrupt crossLinks row in contracts.json');
logger.warn('[group] skipping corrupt crossLinks row in contracts.json');
}
} catch {
skippedCorrupt++;
console.warn('[group] skipping corrupt crossLinks row in contracts.json');
logger.warn('[group] skipping corrupt crossLinks row in contracts.json');
}
}
}

View file

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

View file

@ -6,15 +6,17 @@ 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';
import type { ContractRegistry } from './types.js';
import { logger } from '../logger.js';
export interface SyncOptions {
extractorOverride?:
| ((repo: RepoHandle) => Promise<StoredContract[]>)
@ -96,6 +98,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 +146,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) {
@ -198,7 +212,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
allLinks = [...allLinks, ...wsResult.links];
if (opts?.verbose) {
for (const s of wsResult.stats) {
console.log(
logger.info(
` workspace-deps: discovered ${s.linkCount} cross-${s.ecosystem.toLowerCase()} links from ${s.projectCount} ${s.ecosystem} projects`,
);
}
@ -217,7 +231,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
for (const link of allLinks) {
const dangling = [link.from, link.to].filter((r) => !knownRepos.has(r));
if (dangling.length > 0) {
console.warn(
logger.warn(
`[group/sync] manifest link ${link.type}:${link.contract} references repos not in config.repos: ${dangling.join(', ')} — cross-links will use synthetic UIDs`,
);
}
@ -228,19 +242,21 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
autoContracts.push(...manifestResult.contracts);
manifestCrossLinks = manifestResult.crossLinks;
if (opts?.verbose) {
console.log(
logger.info(
` manifest: ${manifestCrossLinks.length} cross-links from ${allLinks.length} links (${config.links.length} declared + ${allLinks.length - config.links.length} discovered)`,
);
}
}
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 +275,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis
return {
contracts: allContracts,
crossLinks,
unmatched,
unmatched: wildcard.remaining,
missingRepos,
repoSnapshots,
};

View file

@ -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;

View file

@ -1,6 +1,7 @@
import { LRUCache } from 'lru-cache';
import Parser from 'tree-sitter';
import { logger } from '../logger.js';
/**
* Minimal structural shape consumers need when reading Trees back
* through a phase-dependency boundary. Declared here so phases that
@ -49,7 +50,7 @@ export const createASTCache = (maxSize: number = 50): ASTCache => {
// will hand freed memory to scope-resolution.
(tree as unknown as { delete?: () => void }).delete?.();
} catch (e) {
console.warn('Failed to delete tree from WASM memory', e);
logger.warn({ e }, 'Failed to delete tree from WASM memory');
}
},
});

View file

@ -75,6 +75,7 @@ import { extractReturnTypeName, stripNullable } from './type-extractors/shared.j
import type { LiteralTypeInferrer } from './type-extractors/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import { logger } from '../logger.js';
/** Per-file resolved type bindings for exported symbols.
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
export type ExportedTypeMap = Map<string, Map<string, string>>;
@ -784,7 +785,7 @@ export const processCalls = async (
const query = new Parser.Query(lang, queryStr);
matches = query.matches(tree.rootNode);
} catch (queryError) {
console.warn(`Query error for ${file.path}:`, queryError);
logger.warn({ queryError }, `Query error for ${file.path}:`);
continue;
}
@ -1391,7 +1392,7 @@ export const processCalls = async (
if (skippedByLang && skippedByLang.size > 0) {
for (const [lang, count] of skippedByLang.entries()) {
console.warn(
logger.warn(
`[ingestion] Skipped ${count} ${lang} file(s) in call processing — ${lang} parser not available.`,
);
}

View file

@ -7,6 +7,7 @@
import { CommunityNode } from './community-processor.js';
import { logger } from '../logger.js';
// ============================================================================
// TYPES
// ============================================================================
@ -128,7 +129,7 @@ export const enrichClusters = async (
enrichments.set(community.id, enrichment);
} catch (error) {
// On error, fallback to heuristic
console.warn(`Failed to enrich cluster ${community.id}:`, error);
logger.warn({ error }, `Failed to enrich cluster ${community.id}:`);
enrichments.set(community.id, {
name: community.heuristicLabel,
keywords: [],
@ -210,7 +211,7 @@ Output JSON array:
}
}
} catch (error) {
console.warn('Batch enrichment failed, falling back to heuristics:', error);
logger.warn({ error }, 'Batch enrichment failed, falling back to heuristics:');
// Fallback for this batch
for (const community of batch) {
enrichments.set(community.id, {

View file

@ -1,3 +1,4 @@
import { logger } from '../../logger.js';
/**
* COBOL COPY statement expansion engine.
*
@ -454,7 +455,7 @@ export function expandCopies(
if (visited.has(resolvedPath)) {
if (!warnedCircular.has(resolvedPath)) {
warnedCircular.add(resolvedPath);
console.warn(
logger.warn(
`[cobol-copy-expander] Circular COPY detected: ${cs.target} (${resolvedPath}) ` +
`includes itself. Skipping expansion.`,
);
@ -464,7 +465,7 @@ export function expandCopies(
// Max depth exceeded — keep unexpanded
if (depth >= maxDepth) {
console.warn(
logger.warn(
`[cobol-copy-expander] Max expansion depth (${maxDepth}) reached for ` +
`COPY ${cs.target} in ${srcPath}. Skipping expansion.`,
);
@ -475,7 +476,7 @@ export function expandCopies(
if (++totalExpansions > MAX_TOTAL_EXPANSIONS) {
if (!warnedCircular.has('__max_total__')) {
warnedCircular.add('__max_total__');
console.warn(
logger.warn(
`[cobol-copy-expander] Max total expansions (${MAX_TOTAL_EXPANSIONS}) reached ` +
`in ${srcPath}. Skipping further expansions.`,
);

View file

@ -5,6 +5,7 @@ import path from 'path';
import { glob } from 'glob';
import { createIgnoreFilter } from '../../config/ignore-service.js';
import { logger } from '../logger.js';
export interface FileEntry {
path: string;
content: string;
@ -74,10 +75,10 @@ export const walkRepositoryPaths = async (
if (skippedLarge > 0) {
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
const suffix = isDefault ? ', likely generated/vendored' : '';
console.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`);
logger.warn(` Skipped ${skippedLarge} large files (>${maxFileSizeBytes / 1024}KB${suffix})`);
if (isVerboseIngestionEnabled()) {
for (const p of skippedLargePaths) {
console.warn(` - ${p}`);
logger.warn(` - ${p}`);
}
}
}

View file

@ -34,6 +34,7 @@ import type { ResolutionContext } from './model/resolution-context.js';
import { TIER_CONFIDENCE } from './model/resolution-context.js';
import type { HeritageInfo } from './heritage-types.js';
import { logger } from '../logger.js';
/**
* Derive the heritage-resolution strategy for a language from its
* `LanguageProvider`. This is the production wiring that `buildHeritageMap`
@ -237,7 +238,7 @@ export const processHeritage = async (
query = new Parser.Query(treeSitterLang, queryStr);
matches = query.matches(tree.rootNode);
} catch (queryError) {
console.warn(`Heritage query error for ${file.path}:`, queryError);
logger.warn({ queryError }, `Heritage query error for ${file.path}:`);
continue;
}
@ -267,7 +268,7 @@ export const processHeritage = async (
if (skippedByLang && skippedByLang.size > 0) {
for (const [lang, count] of skippedByLang.entries()) {
console.warn(
logger.warn(
`[ingestion] Skipped ${count} ${lang} file(s) in heritage processing — ${lang} parser not available.`,
);
}

View file

@ -27,6 +27,7 @@ import type { SyntaxNode } from './utils/ast-helpers.js';
import { isDev } from './utils/env.js';
import { isRegistryPrimary } from './registry-primary-flag.js';
import { logger } from '../logger.js';
// Type: Map<FilePath, Set<ResolvedFilePath>>
// Stores all files that a given file imports from
export type ImportMap = Map<string, Set<string>>;
@ -324,14 +325,18 @@ export const processImports = async (
matches = query.matches(tree.rootNode);
} catch (queryError: any) {
if (isDev) {
console.group(`🔴 Query Error: ${file.path}`);
console.log('Language:', language);
console.log('Query (first 200 chars):', queryStr.substring(0, 200) + '...');
console.log('Error:', queryError?.message || queryError);
console.log('File content (first 300 chars):', file.content.substring(0, 300));
console.log('AST root type:', tree.rootNode?.type);
console.log('AST has errors:', tree.rootNode?.hasError);
console.groupEnd();
logger.error(
{
file: file.path,
language,
err: queryError?.message || queryError,
queryPreview: queryStr.substring(0, 200) + '...',
contentPreview: file.content.substring(0, 300),
astRootType: tree.rootNode?.type,
astHasError: tree.rootNode?.hasError,
},
'tree-sitter query error',
);
}
if (wasReparsed) (tree as unknown as { delete?: () => void }).delete?.();
@ -346,7 +351,7 @@ export const processImports = async (
const sourceNode = captureMap['import.source'];
if (!sourceNode) {
if (isDev) {
console.log(`⚠️ Import captured but no source node in ${file.path}`);
logger.info(`⚠️ Import captured but no source node in ${file.path}`);
}
return;
}
@ -399,14 +404,14 @@ export const processImports = async (
if (skippedByLang && skippedByLang.size > 0) {
for (const [lang, count] of skippedByLang.entries()) {
console.warn(
logger.warn(
`[ingestion] Skipped ${count} ${lang} file(s) in import processing — ${lang} parser not available.`,
);
}
}
if (isDev) {
console.log(
logger.info(
`📊 Import processing complete: ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`,
);
}
@ -498,7 +503,7 @@ export const processImportsFromExtracted = async (
);
if (isDev) {
console.log(
logger.info(
`📊 Import processing (fast path): ${getResolvedCount()}/${totalImportsFound} imports resolved to graph edges`,
);
}

View file

@ -4,6 +4,7 @@ import type { ImportConfigs } from './import-resolvers/types.js';
import { isDev } from './utils/env.js';
import { logger } from '../logger.js';
// ============================================================================
// LANGUAGE-SPECIFIC CONFIG TYPES
// ============================================================================
@ -82,7 +83,7 @@ export async function loadTsconfigPaths(repoRoot: string): Promise<TsconfigPaths
if (aliases.size > 0) {
if (isDev) {
console.log(`📦 Loaded ${aliases.size} path aliases from ${filename}`);
logger.info(`📦 Loaded ${aliases.size} path aliases from ${filename}`);
}
return { aliases, baseUrl };
}
@ -104,7 +105,7 @@ export async function loadGoModulePath(repoRoot: string): Promise<GoModuleConfig
const match = content.match(/^module\s+(\S+)/m);
if (match) {
if (isDev) {
console.log(`📦 Loaded Go module path: ${match[1]}`);
logger.info(`📦 Loaded Go module path: ${match[1]}`);
}
return { modulePath: match[1] };
}
@ -132,7 +133,7 @@ export async function loadComposerConfig(repoRoot: string): Promise<ComposerConf
}
if (isDev) {
console.log(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`);
logger.info(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`);
}
return { psr4 };
} catch {
@ -178,7 +179,7 @@ export async function loadCSharpProjectConfig(repoRoot: string): Promise<CSharpP
const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
configs.push({ rootNamespace, projectDir });
if (isDev) {
console.log(
logger.info(
`📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`,
);
}
@ -217,7 +218,7 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
if (targets.size > 0) {
if (isDev) {
console.log(`📦 Loaded ${targets.size} Swift package targets`);
logger.info(`📦 Loaded ${targets.size} Swift package targets`);
}
return { targets };
}

View file

@ -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),

View file

@ -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),

View file

@ -8,6 +8,7 @@
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { logger } from '../../logger.js';
import type {
MethodExtractor,
MethodExtractorContext,
@ -158,7 +159,7 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set<string>): SyntaxNode[] {
// Fallback: body field exists but its type is not in bodyNodeTypes.
// This may indicate a config typo — log for debugging if NODE_ENV is development.
if (process.env.NODE_ENV === 'development') {
console.warn(
logger.warn(
`[MethodExtractor] body field type '${bodyField.type}' not in bodyNodeTypes for node '${node.type}'`,
);
}

View file

@ -34,6 +34,7 @@ import {
import type { LanguageProvider } from './language-provider.js';
import type { ParsedFile } from 'gitnexus-shared';
import { WorkerPool } from './workers/worker-pool.js';
import { logger } from '../logger.js';
import type {
ParseWorkerResult,
ParseWorkerInput,
@ -191,7 +192,7 @@ const processParsingWithWorkers = async (
const summary = Array.from(skippedLanguages.entries())
.map(([lang, count]) => `${lang}: ${count}`)
.join(', ');
console.warn(` Skipped unsupported languages: ${summary}`);
logger.warn(` Skipped unsupported languages: ${summary}`);
}
// Final progress
@ -382,7 +383,7 @@ const processParsingSequential = async (
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
console.warn(`Skipping unparseable file: ${file.path}`);
logger.warn(`Skipping unparseable file: ${file.path}`);
continue;
}
@ -408,7 +409,7 @@ const processParsingSequential = async (
query = new Parser.Query(language, queryString);
matches = query.matches(tree.rootNode);
} catch (queryError) {
console.warn(`Query error for ${file.path}:`, queryError);
logger.warn({ queryError }, `Query error for ${file.path}:`);
continue;
}
@ -701,7 +702,7 @@ const processParsingSequential = async (
if (skippedByLang && skippedByLang.size > 0) {
for (const [lang, count] of skippedByLang.entries()) {
console.warn(
logger.warn(
`[ingestion] Skipped ${count} ${lang} file(s) in parsing processing — ${lang} parser not available.`,
);
}
@ -742,7 +743,7 @@ export const processParsing = async (
// in scope-resolution with an empty cache and get re-parsed.
// Surfacing this in PROF mode prevents silent perf cliffs when
// a repo crosses the worker-pool threshold.
console.warn(
logger.warn(
`[scope-resolution prof] worker pool engaged for ${files.length} files — cross-phase tree cache will be empty; scope-resolution re-parses.`,
);
}
@ -757,7 +758,7 @@ export const processParsing = async (
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.warn('Worker pool parsing stopped; continuing with sequential parser:', message);
logger.warn({ message }, 'Worker pool parsing stopped; continuing with sequential parser:');
reportProgress?.(
lastProgress,
files.length,

View file

@ -15,6 +15,7 @@ import { readFileContents } from '../filesystem-walker.js';
import type { StructureOutput } from './structure.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface CobolOutput {
programs: number;
paragraphs: number;
@ -47,7 +48,7 @@ export const cobolPhase: PipelinePhase<CobolOutput> = {
const cobolResult = processCobol(ctx.graph, cobolFiles, allPathSet);
if (isDev) {
console.log(
logger.info(
` COBOL: ${cobolResult.programs} programs, ${cobolResult.paragraphs} paragraphs, ${cobolResult.sections} sections from ${cobolFiles.length} files`,
);
if (
@ -55,12 +56,12 @@ export const cobolPhase: PipelinePhase<CobolOutput> = {
cobolResult.execCicsBlocks > 0 ||
cobolResult.entryPoints > 0
) {
console.log(
logger.info(
` COBOL enriched: ${cobolResult.execSqlBlocks} SQL blocks, ${cobolResult.execCicsBlocks} CICS blocks, ${cobolResult.entryPoints} entry points, ${cobolResult.moves} moves, ${cobolResult.fileDeclarations} file declarations`,
);
}
if (cobolResult.jclJobs > 0) {
console.log(` JCL: ${cobolResult.jclJobs} jobs, ${cobolResult.jclSteps} steps`);
logger.info(` JCL: ${cobolResult.jclJobs} jobs, ${cobolResult.jclSteps} steps`);
}
}

View file

@ -15,6 +15,7 @@ import type { StructureOutput } from './structure.js';
import { processCommunities, type CommunityDetectionResult } from '../community-processor.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface CommunitiesOutput {
communityResult: CommunityDetectionResult;
}
@ -47,7 +48,7 @@ export const communitiesPhase: PipelinePhase<CommunitiesOutput> = {
});
if (isDev) {
console.log(
logger.info(
`🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`,
);
}

View file

@ -23,6 +23,7 @@ import { topologicalLevelSort } from '../utils/graph-sort.js';
import type { KnowledgeGraph } from '../../graph/types.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
/** Max AST trees to keep in LRU cache for cross-file binding propagation. */
const AST_CACHE_CAP = 50;
@ -60,7 +61,7 @@ export async function runCrossFileBindingPropagation(
const { levels, cycleCount } = topologicalLevelSort(ctx.importMap);
if (isDev && cycleCount > 0) {
console.log(`🔄 ${cycleCount} files in import cycles (processed last in undefined order)`);
logger.info(`🔄 ${cycleCount} files in import cycles (processed last in undefined order)`);
}
let filesWithGaps = 0;
@ -88,7 +89,7 @@ export async function runCrossFileBindingPropagation(
const gapRatio = totalFiles > 0 ? filesWithGaps / totalFiles : 0;
if (gapRatio < CROSS_FILE_SKIP_THRESHOLD && filesWithGaps < gapThreshold) {
if (isDev) {
console.log(
logger.info(
`⏭️ Cross-file re-resolution skipped (${filesWithGaps}/${totalFiles} files, ${(gapRatio * 100).toFixed(1)}% < ${CROSS_FILE_SKIP_THRESHOLD * 100}% threshold)`,
);
}
@ -193,7 +194,7 @@ export async function runCrossFileBindingPropagation(
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
if (isDev)
console.log(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`);
logger.info(`⚠️ Cross-file re-resolution capped at ${MAX_CROSS_FILE_REPROCESS} files`);
break;
}
}
@ -204,7 +205,7 @@ export async function runCrossFileBindingPropagation(
const elapsed = Date.now() - crossFileStart;
const totalElapsed = Date.now() - pipelineStart;
const reResolutionPct = totalElapsed > 0 ? ((elapsed / totalElapsed) * 100).toFixed(1) : '0';
console.log(
logger.info(
`🔗 Cross-file re-resolution: ${crossFileResolved} candidates re-processed` +
` in ${elapsed}ms (${reResolutionPct}% of total ingestion time so far)`,
);

View file

@ -36,6 +36,7 @@ import type { ParseOutput } from './parse.js';
import { runCrossFileBindingPropagation } from './cross-file-impl.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface CrossFileOutput {
/** Number of files re-processed during cross-file propagation. */
filesReprocessed: number;
@ -59,11 +60,11 @@ export const crossFilePhase: PipelinePhase<CrossFileOutput> = {
if (isDev) {
if (bindingAccumulator.totalBindings > 0) {
const memKB = Math.round(bindingAccumulator.estimateMemoryBytes() / 1024);
console.log(
logger.info(
`📦 BindingAccumulator: ${bindingAccumulator.totalBindings} bindings across ${bindingAccumulator.fileCount} files (~${memKB} KB)`,
);
} else if (totalFiles > 0) {
console.log(
logger.info(
`📦 BindingAccumulator: EMPTY — 0 bindings across 0 files despite ${totalFiles} parsed files. If the codebase has typed bindings, this indicates an upstream regression.`,
);
}

View file

@ -15,6 +15,7 @@ import { readFileContents } from '../filesystem-walker.js';
import type { StructureOutput } from './structure.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface MarkdownOutput {
/** Number of markdown sections extracted. */
sections: number;
@ -48,7 +49,7 @@ export const markdownPhase: PipelinePhase<MarkdownOutput> = {
const mdResult = processMarkdown(ctx.graph, mdFiles, allPathSet);
if (isDev) {
console.log(
logger.info(
` Markdown: ${mdResult.sections} sections, ${mdResult.links} cross-links from ${mdFiles.length} files`,
);
}

View file

@ -15,6 +15,7 @@ import type { StructureOutput } from './structure.js';
import { computeMRO } from '../mro-processor.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface MROOutput {
entries: number;
ambiguityCount: number;
@ -42,7 +43,7 @@ export const mroPhase: PipelinePhase<MROOutput> = {
const mroResult = computeMRO(ctx.graph);
if (isDev && mroResult.entries.length > 0) {
console.log(
logger.info(
`🔀 MRO: ${mroResult.entries.length} classes analyzed, ${mroResult.ambiguityCount} ambiguities, ${mroResult.overrideEdges} METHOD_OVERRIDES, ${mroResult.methodImplementsEdges} METHOD_IMPLEMENTS`,
);
}

View file

@ -16,6 +16,7 @@ import type { ExtractedORMQuery } from '../workers/parse-worker.js';
import type { KnowledgeGraph } from '../../graph/types.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface ORMOutput {
edgesCreated: number;
modelCount: number;
@ -91,7 +92,7 @@ function processORMQueries(
}
if (isDev) {
console.log(
logger.info(
`ORM dataflow: ${edgesCreated} QUERIES edges, ${modelNodes.size} models (${queries.length} total calls)`,
);
}

View file

@ -69,6 +69,7 @@ import { isDev } from '../utils/env.js';
import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js';
import { extractORMQueriesInline } from './orm-extraction.js';
import { logger } from '../../logger.js';
// ── Constants ──────────────────────────────────────────────────────────────
/** Max bytes of source content to load per parse chunk. */
@ -136,7 +137,7 @@ export async function runChunkedParseAndResolve(
}
}
for (const [lang, count] of skippedByLang) {
console.warn(
logger.warn(
`Skipping ${count} ${lang} file(s) — ${lang} parser not available (native binding may not have built). Try: npm rebuild tree-sitter-${lang}`,
);
}
@ -171,7 +172,7 @@ export async function runChunkedParseAndResolve(
if (isDev) {
const totalMB = parseableScanned.reduce((s, f) => s + f.size, 0) / (1024 * 1024);
console.log(
logger.info(
`📂 Scan: ${totalFiles} paths, ${totalParseable} parseable (${totalMB.toFixed(0)}MB), ${numChunks} chunks @ ${CHUNK_BYTE_BUDGET / (1024 * 1024)}MB budget`,
);
}
@ -220,9 +221,9 @@ export async function runChunkedParseAndResolve(
}
workerPool = createWorkerPool(workerUrl);
} catch (err) {
console.warn(
logger.warn(
{ err: (err as Error).message },
'Worker pool creation failed, using sequential fallback:',
(err as Error).message,
);
}
}
@ -339,7 +340,7 @@ export async function runChunkedParseAndResolve(
exportedTypeMap,
);
if (isDev && enrichedCount > 0) {
console.log(
logger.info(
`🔗 E1: Seeded ${enrichedCount} cross-file receiver types (chunk ${chunkIdx + 1})`,
);
}
@ -538,7 +539,7 @@ export async function runChunkedParseAndResolve(
const rcStats = ctx.getStats();
const total = rcStats.cacheHits + rcStats.cacheMisses;
const hitRate = total > 0 ? ((rcStats.cacheHits / total) * 100).toFixed(1) : '0';
console.log(
logger.info(
`🔍 Resolution cache: ${rcStats.cacheHits} hits, ${rcStats.cacheMisses} misses (${hitRate}% hit rate)`,
);
}
@ -554,15 +555,15 @@ export async function runChunkedParseAndResolve(
bindingAccumulator.finalize();
const enriched = enrichExportedTypeMap(bindingAccumulator, graph, exportedTypeMap);
if (isDev && enriched > 0) {
console.log(
logger.info(
`🔗 Worker TypeEnv enrichment: ${enriched} fixpoint-inferred exports added to ExportedTypeMap`,
);
}
} catch (enrichErr) {
if (isDev) {
console.warn(
logger.warn(
{ err: (enrichErr as Error).message },
'Post-fallback finalize/enrich failed during cleanup:',
(enrichErr as Error).message,
);
}
}
@ -571,7 +572,7 @@ export async function runChunkedParseAndResolve(
if (!hasSynthesized) {
const synthesized = synthesizeWildcardImportBindings(graph, ctx);
if (isDev && synthesized > 0) {
console.log(
logger.info(
`🔗 Synthesized ${synthesized} additional wildcard import bindings (Go/Ruby/C++/Swift/Python)`,
);
}

View file

@ -19,6 +19,7 @@ import { processProcesses, type ProcessDetectionResult } from '../process-proces
import { generateId } from '../../../lib/utils.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface ProcessesOutput {
processResult: ProcessDetectionResult;
}
@ -67,7 +68,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
);
if (isDev) {
console.log(
logger.info(
`🔄 Process detection: ${processResult.stats.totalProcesses} processes found (${processResult.stats.crossCommunityCount} cross-community)`,
);
}
@ -167,7 +168,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
}
}
if (isDev && linked > 0) {
console.log(`🔗 Linked ${linked} Route/Tool nodes to execution flows`);
logger.info(`🔗 Linked ${linked} Route/Tool nodes to execution flows`);
}
}

View file

@ -32,6 +32,7 @@ import { generateId } from '../../../lib/utils.js';
import { readFileContents } from '../filesystem-walker.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
const EXPO_NAV_PATTERNS = [
/router\.(push|replace|navigate)\(\s*['"`]([^'"`]+)['"`]/g,
/<Link\s+[^>]*href=\s*['"`]([^'"`]+)['"`]/g,
@ -174,7 +175,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
}
if (isDev) {
console.log(
logger.info(
`🗺️ Route registry: ${routeRegistry.size} routes${duplicateRoutes > 0 ? ` (${duplicateRoutes} duplicate URLs skipped)` : ''}`,
);
}
@ -224,7 +225,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
linkedCount++;
}
if (isDev && linkedCount > 0) {
console.log(
logger.info(
`🛡️ Linked ${mwPath} middleware [${mwLabel.join(', ')}] to ${linkedCount} routes`,
);
}
@ -290,7 +291,7 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeURLToFile, consumerContents);
if (isDev) {
console.log(
logger.info(
`🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`,
);
}

View file

@ -15,6 +15,7 @@
import type { PipelinePhase, PipelineContext, PhaseResult } from './types.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
/**
* Validate that the phases form a valid dependency graph (no cycles, all deps present).
* Returns phases in topological execution order.
@ -176,7 +177,7 @@ export async function runPipeline(
const start = Date.now();
if (isDev) {
console.log(`▶ Phase: ${phase.name}`);
logger.info(`▶ Phase: ${phase.name}`);
}
// Only expose declared dependencies — prevents hidden coupling to undeclared phases.
@ -220,7 +221,7 @@ export async function runPipeline(
});
if (isDev) {
console.log(`✓ Phase: ${phase.name} (${durationMs}ms)`);
logger.info(`✓ Phase: ${phase.name} (${durationMs}ms)`);
}
}

View file

@ -16,6 +16,7 @@ import { generateId } from '../../../lib/utils.js';
import { readFileContents } from '../filesystem-walker.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
export interface ToolDef {
name: string;
filePath: string;
@ -104,7 +105,7 @@ export const toolsPhase: PipelinePhase<ToolsOutput> = {
}
if (isDev) {
console.log(`🔧 Tool registry: ${toolDefs.length} tools detected`);
logger.info(`🔧 Tool registry: ${toolDefs.length} tools detected`);
}
}

View file

@ -17,6 +17,7 @@ import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { isDev } from './utils/env.js';
import { logger } from '../logger.js';
// ============================================================================
// CONFIGURATION
// ============================================================================
@ -319,13 +320,13 @@ const findEntryPoints = (
// DEBUG: Log top candidates with new scoring details
if (sorted.length > 0 && isDev) {
console.log(`[Process] Top 10 entry point candidates (new scoring):`);
logger.info(`[Process] Top 10 entry point candidates (new scoring):`);
sorted.slice(0, 10).forEach((c, i) => {
const node = graph.getNode(c.id);
const exported = node?.properties.isExported ? '✓' : '✗';
const shortPath = node?.properties.filePath?.split('/').slice(-2).join('/') || '';
console.log(` ${i + 1}. ${node?.properties.name} [exported:${exported}] (${shortPath})`);
console.log(` score: ${c.score.toFixed(2)} = [${c.reasons.join(' × ')}]`);
logger.info(` ${i + 1}. ${node?.properties.name} [exported:${exported}] (${shortPath})`);
logger.info(` score: ${c.score.toFixed(2)} = [${c.reasons.join(' × ')}]`);
});
}

View file

@ -28,6 +28,7 @@ import type { ParsedFile } from 'gitnexus-shared';
import { extract as extractScope } from './scope-extractor.js';
import type { LanguageProvider } from './language-provider.js';
import { logger } from '../logger.js';
/** Callback used to report scope-extraction warnings to the host (worker or direct). */
export type ScopeBridgeWarn = (message: string) => void;
@ -53,7 +54,7 @@ export function extractParsedFile(
err instanceof Error ? err.message : String(err)
}`;
if (onWarn !== undefined) onWarn(message);
else console.warn(message);
logger.warn(message);
return undefined;
}
}

View file

@ -38,6 +38,7 @@ import { runScopeResolution } from './run.js';
import { SCOPE_RESOLVERS } from './registry.js';
import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js';
import { logger } from '../../../logger.js';
export interface ScopeResolutionOutput {
/** True when at least one language ran. */
readonly ran: boolean;
@ -144,7 +145,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
resolutionConfig,
onWarn: (msg) => {
if (isSemanticModelValidatorEnabled()) {
console.warn(`[scope-resolution:${lang}] ${msg}`);
logger.warn(`[scope-resolution:${lang}] ${msg}`);
}
},
},
@ -162,7 +163,7 @@ export const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput> = {
});
if (isDev) {
console.log(
logger.info(
`[scope-resolution:${lang}] ${stats.filesProcessed} files → ${stats.importsEmitted} IMPORTS + ${stats.referenceEdgesEmitted} reference edges (${stats.resolve.unresolved} unresolved sites, ${stats.referenceSkipped} skipped)`,
);
}

View file

@ -41,6 +41,7 @@ import { emitImportEdges } from '../graph-bridge/imports-to-edges.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
import { logger } from '../../../logger.js';
interface RunScopeResolutionInput {
readonly graph: KnowledgeGraph;
/**
@ -279,7 +280,7 @@ export function runScopeResolution(
if (PROF) {
const tEnd = process.hrtime.bigint();
const ns = (a: bigint, b: bigint): number => Number(b - a) / 1_000_000;
console.warn(
logger.warn(
`[scope-resolution prof] extract=${ns(tStart, tExtract).toFixed(0)}ms` +
` finalize=${ns(tExtract, tFinalize).toFixed(0)}ms` +
` propagate=${ns(tFinalize, tPropagate).toFixed(0)}ms` +

View file

@ -24,6 +24,7 @@ import {
import type { SemanticModel } from './model/index.js';
import type { NodeLabel } from 'gitnexus-shared';
import { logger } from '../logger.js';
/**
* Per-file scoped type environment: maps (scope, variableName) typeName.
* Scope-aware: variables inside functions are keyed by function name,
@ -769,7 +770,7 @@ const resolveFixpointBindings = (
if (iter === MAX_FIXPOINT_ITERATIONS - 1 && process.env.GITNEXUS_DEBUG) {
const unresolved = pendingItems.length - resolved.size;
if (unresolved > 0) {
console.warn(
logger.warn(
`[type-env] fixpoint hit iteration cap (${MAX_FIXPOINT_ITERATIONS}), ${unresolved} items unresolved`,
);
}

View file

@ -1,5 +1,6 @@
import { TREE_SITTER_MAX_BUFFER } from '../constants.js';
import { logger } from '../../logger.js';
/** Default threshold (512 KB). Files larger than this are skipped by the walker. */
export const DEFAULT_MAX_FILE_SIZE_BYTES = 512 * 1024;
@ -11,7 +12,7 @@ const warned = new Set<string>();
const warnOnce = (key: string, message: string): void => {
if (warned.has(key)) return;
warned.add(key);
console.warn(message);
logger.warn(message);
};
/**

View file

@ -85,6 +85,7 @@ import type { LanguageProvider } from '../language-provider.js';
import type { ParsedFile } from 'gitnexus-shared';
import { extractParsedFile } from '../scope-extractor-bridge.js';
import { logger } from '../../logger.js';
// ============================================================================
// Types for serializable results
// ============================================================================
@ -1385,7 +1386,7 @@ const processFileGroup = (
if (parentPort) {
parentPort.postMessage({ type: 'warning', message });
} else {
console.warn(message);
logger.warn(message);
}
return;
}
@ -1414,7 +1415,7 @@ const processFileGroup = (
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (err) {
console.warn(
logger.warn(
`Failed to parse file ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
);
continue;
@ -1427,7 +1428,7 @@ const processFileGroup = (
try {
matches = query.matches(tree.rootNode);
} catch (err) {
console.warn(
logger.warn(
`Query execution failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
);
continue;
@ -1447,7 +1448,7 @@ const processFileGroup = (
file.path,
(message) => {
if (parentPort) parentPort.postMessage({ type: 'warning', message });
else console.warn(message);
else logger.warn(message);
},
tree,
);

View file

@ -3,6 +3,7 @@ import os from 'node:os';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { logger } from '../../logger.js';
export interface WorkerPool {
/**
* Dispatch items across workers. Items are split into bounded jobs, each job
@ -94,6 +95,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 +235,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) => {
@ -258,11 +298,18 @@ export const createWorkerPool = (
splitDepth: job.splitDepth + 1,
timeoutMs: nextTimeout,
};
console.warn(
`Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
`(${job.items.length} items, ${job.estimatedBytes} bytes, last progress: ${lastProgress}). ` +
`Splitting into ${first.items.length}/${second.items.length} item jobs with ` +
`${nextTimeout / 1000}s timeout.`,
logger.warn(
{
workerIndex,
timeoutSec: job.timeoutMs / 1000,
items: job.items.length,
estimatedBytes: job.estimatedBytes,
lastProgress,
firstSplitItems: first.items.length,
secondSplitItems: second.items.length,
nextTimeoutSec: nextTimeout / 1000,
},
`Worker ${workerIndex} parse job idle timeout. Splitting into ${first.items.length}/${second.items.length} item jobs.`,
);
// Preserve intuitive retry order; final result order is still enforced by startIndex sort.
jobs.unshift(first, second);
@ -271,10 +318,15 @@ export const createWorkerPool = (
const nextAttempt = job.attempt + 1;
if (nextAttempt <= poolOptions.maxTimeoutRetries) {
console.warn(
`Worker ${workerIndex} parse job idle timeout after ${job.timeoutMs / 1000}s ` +
`(single item, attempt ${nextAttempt}/${poolOptions.maxTimeoutRetries + 1}). ` +
`Retrying with ${nextTimeout / 1000}s timeout.`,
logger.warn(
{
workerIndex,
timeoutSec: job.timeoutMs / 1000,
attempt: nextAttempt,
maxAttempts: poolOptions.maxTimeoutRetries + 1,
nextTimeoutSec: nextTimeout / 1000,
},
`Worker ${workerIndex} parse job idle timeout (single item). Retrying with ${nextTimeout / 1000}s timeout.`,
);
jobs.unshift({
...job,
@ -341,9 +393,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--;
@ -365,7 +415,7 @@ export const createWorkerPool = (
reportProgress();
} else if (msg.type === 'warning') {
resetIdleTimer();
console.warn(msg.message);
logger.warn(msg.message);
} else if (msg.type === 'sub-batch-done') {
waitingForFlush = true;
resetIdleTimer();

View file

@ -1,6 +1,7 @@
import { spawn } from 'child_process';
import { fileURLToPath } from 'node:url';
import { LBUG_MAX_DB_SIZE } from './lbug-config.js';
import { logger } from '../logger.js';
const DEFAULT_EXTENSION_INSTALL_TIMEOUT_MS = 15_000;
const EXTENSION_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
@ -188,7 +189,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 ?? ((msg: string) => logger.warn(msg));
if (policy === 'never') {
this.markUnavailable(name, label, 'extension install policy is "never"', warn);

View file

@ -24,6 +24,7 @@ import {
} from './lbug-config.js';
import { isVectorExtensionSupportedByPlatform } from '../platform/capabilities.js';
import { logger } from '../logger.js';
// ---------------------------------------------------------------------------
// Relationship CSV splitting — extracted for testability (PR #818)
// ---------------------------------------------------------------------------
@ -257,18 +258,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 +284,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 +331,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)}`);
logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
}
}
}
@ -701,7 +684,7 @@ export const insertNodeToLbug = async (
return false;
} catch (e: any) {
// Node may already exist or other error
console.error(`Failed to insert ${label} node:`, e.message);
logger.error({ err: e.message }, `Failed to insert ${label} node:`);
return false;
}
};
@ -1028,14 +1011,14 @@ export const fetchExistingEmbeddingHashes = async (
const nodeId = r.nodeId ?? r[0];
if (nodeId) map.set(nodeId, STALE_HASH_SENTINEL);
}
console.log(
logger.info(
`[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(
logger.info(
`[embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`,
);
return undefined;
@ -1047,19 +1030,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;

View file

@ -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();

375
gitnexus/src/core/logger.ts Normal file
View file

@ -0,0 +1,375 @@
/**
* Centralized structured logger for GitNexus.
*
* Wraps `pino` so the rest of the codebase imports from one place. Pino's
* NDJSON output is structurally log-injection-resistant (CWE-117 / CodeQL
* `js/log-injection`): each record is a single JSON object on its own line,
* with all string field values JSON-escaped. This replaces hand-rolled
* sanitizers (see PR #1329 history) that had recurring edge-case gaps
* (undefined Error.message, U+2028/U+2029, ANSI/C0).
*
* Usage:
* import { logger, createLogger } from '../core/logger.js';
* logger.warn({ groupDir }, 'msg');
* const childLogger = createLogger('bridge-db', { debugEnvVar: 'GITNEXUS_DEBUG_BRIDGE' });
*
* Operator semantics:
* - Default level: 'info' (matches pino default; preserves visibility of
* existing `console.log` migrations)
* - When `opts.debugEnvVar` is set and that env var is truthy at
* createLogger time, that named child logs at level 'debug'
* - Output is NDJSON in production / CI / vitest. pino-pretty is used only
* when stdout is a TTY AND CI is unset AND VITEST is unset, so test
* and pipeline output stay parseable.
*
* Test capture:
* The exported `logger` singleton is a Proxy that forwards every call to a
* lazily-built pino instance. Tests use `_captureLogger()` to redirect that
* inner instance to a memory stream so they can assert on records the
* production code logged. See `gitnexus/test/unit/logger.test.ts` for the
* pattern.
*/
import pino, { type Logger, type LoggerOptions, type DestinationStream } from 'pino';
import { Writable } from 'node:stream';
import { createRequire } from 'node:module';
export interface CreateLoggerOptions {
/** When set, this env var (truthy at construction time) bumps level to 'debug'. */
debugEnvVar?: string;
/** Override destination stream — primarily for tests. */
destination?: DestinationStream;
}
function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
const v = value.toLowerCase();
return v !== '' && v !== '0' && v !== 'false' && v !== 'no' && v !== 'off';
}
function shouldUsePretty(): boolean {
// Logger writes to stderr (fd 2) so CLI data on stdout (fd 1) stays clean.
// Pretty-print only when stderr is a TTY and not in CI/test environments.
return (
process.stderr.isTTY === true &&
!isTruthyEnv(process.env.CI) &&
!isTruthyEnv(process.env.VITEST)
);
}
/**
* Default pino destination writes to stderr (fd 2) so CLI commands can
* keep stdout (fd 1) clean for tool data output (#324). Pino defaults to
* stdout; we override here.
*
* `sync: false` (SonicBoom buffered writes) so logger calls don't issue a
* blocking `write(2)` syscall on every record. Hot paths (parse-impl,
* ingestion phases, per-query backend calls) pay the cost without it.
*
* The buffered-write trade-off is record loss on hard exit. We mitigate via:
* - A `process.on('beforeExit')` hook below that calls `flushSync()` on
* normal exits.
* - The exported `flushLoggerSync()` helper, which entry-point shutdown
* handlers (SIGINT/SIGTERM) MUST call before `process.exit(N)` so
* in-flight buffered records still reach stderr.
* - `pino.final(...)` integration in `uncaughtException` / `unhandledRejection`
* handlers (see `gitnexus/src/cli/serve.ts` and `gitnexus/src/server/api.ts`).
*
* Skipped under `VITEST` so vitest's between-test cleanup doesn't fight
* `_captureLogger()`'s lifecycle. Tests use an in-memory destination via
* `_captureLogger()` and never reach this branch.
*/
let _dest: ReturnType<typeof pino.destination> | undefined;
function defaultDestination(): DestinationStream {
if (_dest) return _dest;
_dest = pino.destination({ dest: 2, sync: false });
return _dest;
}
/**
* Flush any buffered records on the default destination. Entry-point
* shutdown handlers (`SIGINT` / `SIGTERM`) MUST call this before
* `process.exit(N)` otherwise async-buffered records are lost on hard
* exit. No-op when the destination hasn't been constructed yet (logger
* module imported but never emitted) or when called from `_captureLogger`
* test mode (tests use an in-memory destination).
*/
export function flushLoggerSync(): void {
if (!_dest) return;
try {
_dest.flushSync();
} catch {
// Defend against a destination that has already been closed (e.g.,
// double-flush on rapid shutdown). Losing the flush attempt is the
// correct trade-off vs. throwing during shutdown.
}
}
/**
* Idempotent registration: `process.on('beforeExit')` flushes the buffered
* destination before normal exit. Skipped under VITEST to avoid interfering
* with `_captureLogger()`'s lifecycle and vitest's per-worker cleanup.
*/
let _flushHookInstalled = false;
function installFlushHook(): void {
if (_flushHookInstalled) return;
if (isTruthyEnv(process.env.VITEST)) return;
_flushHookInstalled = true;
process.on('beforeExit', () => {
flushLoggerSync();
});
}
/**
* Probe whether `pino-pretty` is resolvable from this module. Cached for
* the lifetime of the process the resolve cost only happens once, and
* the one-time stderr warning on miss only fires once.
*
* Production installs ship pino-pretty as a runtime dependency (see
* gitnexus/package.json). The probe is the safety net for `--omit=optional`,
* `--no-package-lock` style installs and for any environment where the
* module turns out to be missing for reasons we can't predict — pino's
* own transport-resolution path resolves the target lazily at FIRST log
* write, so without this probe a missing module would throw deep inside
* the pino call site rather than at logger construction.
*/
let _prettyAvailable: boolean | null = null;
const _require = createRequire(import.meta.url);
function isPrettyAvailable(): boolean {
if (_prettyAvailable !== null) return _prettyAvailable;
try {
_require.resolve('pino-pretty');
_prettyAvailable = true;
} catch {
_prettyAvailable = false;
// One-time stderr warning so operators learn why TTY output is plain
// NDJSON instead of pretty-printed. Use realStderrWrite-style direct
// write — going through `logger` here would recurse.
process.stderr.write(
'[gitnexus:logger] pino-pretty unavailable; falling back to NDJSON on stderr\n',
);
}
return _prettyAvailable;
}
/**
* @internal Test-only reset for the pino-pretty availability cache. Lets
* unit tests exercise both resolve outcomes within the same vitest worker.
*/
export function _resetPrettyAvailableCache(): void {
_prettyAvailable = null;
}
/**
* Build the pino-pretty transport options. Internal exported only so unit
* tests can exercise the probe path without going through `shouldUsePretty()`
* (which is structurally false under vitest).
*/
export function _tryBuildPrettyTransport(): LoggerOptions['transport'] | undefined {
if (!isPrettyAvailable()) return undefined;
return {
target: 'pino-pretty',
options: {
// Route to stderr (fd 2) so pretty output doesn't contaminate
// CLI tool data on stdout (fd 1). pino-pretty's default is fd 1,
// which would interleave with `gitnexus query | jq` output.
destination: 2,
colorize: true,
translateTime: 'SYS:HH:MM:ss.l',
ignore: 'pid,hostname',
},
};
}
/**
* Pino accepts `'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'`.
* Anything else is silently ignored at runtime; we narrow here so a typo in
* the env var produces the documented default rather than masking the issue.
*/
const PINO_LEVELS = new Set(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']);
function resolveBaseLevel(): string {
const fromEnv = process.env.GITNEXUS_LOG_LEVEL;
if (fromEnv && PINO_LEVELS.has(fromEnv.toLowerCase())) {
return fromEnv.toLowerCase();
}
return 'info';
}
function buildBaseOptions(): LoggerOptions {
const opts: LoggerOptions = {
level: resolveBaseLevel(),
base: undefined,
};
if (shouldUsePretty()) {
const transport = _tryBuildPrettyTransport();
if (transport) opts.transport = transport;
}
return opts;
}
/**
* Create a named child logger. When `opts.destination` is provided it bypasses
* the default stdout sink (useful for test capture). When `opts.debugEnvVar` is
* set and truthy at call time, the child runs at 'debug' level.
*/
export function createLogger(name: string, opts?: CreateLoggerOptions): Logger {
const debugRequested = opts?.debugEnvVar ? isTruthyEnv(process.env[opts.debugEnvVar]) : false;
if (opts?.destination) {
return pino(
{ level: debugRequested ? 'debug' : 'info', base: undefined, name },
opts.destination,
);
}
const base = buildBaseOptions();
// When using a transport (pino-pretty), pino manages the destination
// internally and we cannot pass one explicitly. When transport is absent,
// route to stderr so stdout stays clean for CLI data output.
let root: Logger;
if (base.transport) {
root = pino({ ...base, level: debugRequested ? 'debug' : base.level });
} else {
root = pino({ ...base, level: debugRequested ? 'debug' : base.level }, defaultDestination());
// The default destination is buffered (`sync: false`); register the
// graceful-exit flush hook now that we know the destination will be
// used. Idempotent — runs at most once per process. Skipped under
// VITEST so test cleanup doesn't fight `_captureLogger`.
installFlushHook();
}
return root.child({ name });
}
/* ------------------------------------------------------------------ */
/* Default singleton (Proxy-backed for test capture) */
/* ------------------------------------------------------------------ */
let _activeDestination: DestinationStream | undefined;
let _cached: Logger | undefined;
function _getInner(): Logger {
if (_cached) return _cached;
// Always go through createLogger so future defaults (serializers, redaction,
// formatters) apply uniformly. The destination override is honored when set
// by `_captureLogger()` below.
_cached = createLogger(
'gitnexus',
_activeDestination ? { destination: _activeDestination } : undefined,
);
return _cached;
}
/**
* Default singleton logger (`name: 'gitnexus'`). Backed by a Proxy so test
* capture (`_captureLogger()`) can redirect output without breaking modules
* that already imported the singleton at module-load time.
*/
export const logger = new Proxy({} as Logger, {
get(_target, prop) {
const inner = _getInner();
// Reflect.get keeps symbol-keyed lookups (e.g. Symbol.toPrimitive) intact;
// a `prop as string` cast would silently coerce them to the wrong key.
const value = Reflect.get(inner as object, prop, inner);
if (typeof value === 'function') {
return (value as (...a: unknown[]) => unknown).bind(inner);
}
return value;
},
}) as Logger;
/**
* Shape of a parsed pino record. `level`, `time`, and `msg` are always
* present; `name` is set when emitted from a named child logger; arbitrary
* additional fields appear when callers pass a structured first arg.
*
* Exported so test helpers and downstream skills can type-narrow capture
* results without inline `Record<string, unknown>` casts.
*/
export interface PinoLogRecord {
level: number;
time: number;
msg: string;
name?: string;
[key: string]: unknown;
}
/**
* In-memory Writable used by `_captureLogger()` and by tests that build
* their own pino destination. Exported so the shape lives in one place
* (previously duplicated between this module and `logger.test.ts`).
*
* `text()` and `records()` are convenience helpers test code calls. They
* don't appear in production hot paths only test destinations capture
* here so the surface is intentionally small.
*/
export class MemoryWritable extends Writable {
chunks: string[] = [];
_write(chunk: Buffer | string, _enc: BufferEncoding, cb: (err?: Error | null) => void): void {
this.chunks.push(typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
cb();
}
/** Concatenate every captured write back into a single string. */
text(): string {
return this.chunks.join('');
}
/** Parse captured writes as one NDJSON record per non-empty line. */
records(): PinoLogRecord[] {
return this.text()
.split('\n')
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as PinoLogRecord);
}
}
export interface LoggerCapture {
records(): PinoLogRecord[];
text(): string;
restore(): void;
}
/**
* Test helper. Redirects the default `logger` singleton to an in-memory
* stream and returns a capture object plus a restore function.
*
* Pattern:
* let cap: LoggerCapture;
* beforeEach(() => { cap = _captureLogger(); });
* afterEach(() => { cap.restore(); });
* it('warns', () => {
* fnUnderTest();
* expect(cap.records().some(r => r.msg?.includes('clamping'))).toBe(true);
* });
*
* Not a public API; underscore-prefixed and called only from test code.
* Throws if a previous capture is still active see the body for context.
*/
export function _captureLogger(): LoggerCapture {
// Guard against double-capture: forgetting `restore()` between two
// `_captureLogger()` calls silently abandoned the previous capture and
// corrupted logger state for the rest of the vitest worker. Throwing here
// surfaces the bug at the moment of misuse instead of as inscrutable
// missing-records assertions in unrelated tests.
if (_activeDestination !== undefined) {
throw new Error(
'_captureLogger: a previous capture is still active — call restore() before starting a new one.',
);
}
const w = new MemoryWritable();
_activeDestination = w;
_cached = undefined;
return {
records: () =>
w.chunks
.join('')
.split('\n')
.filter((l) => l.length > 0)
.map((l) => JSON.parse(l) as PinoLogRecord),
text: () => w.chunks.join(''),
restore: () => {
_activeDestination = undefined;
_cached = undefined;
},
};
}

View file

@ -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.`,
);
}
}

View file

@ -2,6 +2,7 @@ import Parser from 'tree-sitter';
import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
import { logger } from '../logger.js';
const _require = createRequire(import.meta.url);
/**
@ -175,8 +176,14 @@ 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);
// Severity routes to the correct pino level. Both go to stderr (pino's
// default destination), so MCP stdio framing is preserved either way —
// the level tag drives log filtering, not channel selection.
if (result.severity === 'error') {
logger.error(message);
} else {
logger.warn(message);
}
};
export const resolveLanguageKey = (language: SupportedLanguages, filePath?: string): string =>

View file

@ -10,6 +10,7 @@
import { spawn, execSync } from 'child_process';
import type { LLMResponse, CallLLMOptions } from './llm-client.js';
import { logger } from '../logger.js';
export interface CursorConfig {
model?: string;
workingDirectory?: string;
@ -21,7 +22,7 @@ function isVerbose(): boolean {
function verboseLog(...args: unknown[]): void {
if (isVerbose()) {
console.log('[cursor-cli]', ...args);
logger.info({ args }, '[cursor-cli]');
}
}

View file

@ -1,3 +1,4 @@
import { logger } from '../logger.js';
/**
* LLM Client for Wiki Generation
*
@ -135,7 +136,7 @@ export async function callLLM(
// Warn when using Azure legacy deployment URL without api-version
if (azure && !config.apiVersion && config.baseUrl.includes('/deployments/')) {
console.warn(
logger.warn(
'[gitnexus] Warning: Azure legacy deployment URL detected but no api-version set. Add --api-version 2024-10-21 or use the v1 API format.',
);
}

View file

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

View file

@ -15,6 +15,7 @@ import { resolveEmbeddingConfig } from '../../core/embeddings/config.js';
import { applyHfEnvOverrides } from '../../core/embeddings/hf-env.js';
import { silenceStdout, restoreStdout, realStderrWrite } from '../../core/lbug/pool-adapter.js';
import { logger } from '../../core/logger.js';
// Model config
const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs';
@ -51,7 +52,7 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
applyHfEnvOverrides(env);
const embeddingConfig = resolveEmbeddingConfig();
console.error('GitNexus: Loading embedding model (first search may take a moment)...');
logger.info('GitNexus: Loading embedding model (first search may take a moment)...');
const devicesToTry: Array<'dml' | 'cuda' | 'cpu'> =
embeddingConfig.device === 'dml' || embeddingConfig.device === 'cuda'
@ -82,7 +83,7 @@ export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
restoreStdout();
process.stderr.write = realStderrWrite;
}
console.error(`GitNexus: Embedding model loaded (${device})`);
logger.info({ device }, 'GitNexus: Embedding model loaded');
return embedderInstance!;
} catch {
if (device === 'cpu') throw new Error('Failed to load embedding model');

View file

@ -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';

View file

@ -41,6 +41,7 @@ import {
} from '../../core/platform/capabilities.js';
import { PhaseTimer } from '../../core/search/phase-timer.js';
import { checkStaleness, checkCwdMatch } from '../../core/git-staleness.js';
import { logger } from '../../core/logger.js';
// AI context generation is CLI-only (gitnexus analyze)
// import { generateAIContextFiles } from '../../cli/ai-context.js';
@ -164,29 +165,27 @@ const confidenceForRelType = (relType: string | undefined): number =>
/** Structured error logging for query failures — replaces empty catch blocks */
function logQueryError(context: string, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err);
console.error(`GitNexus [${context}]: ${msg}`);
logger.error({ context, err: msg }, 'GitNexus query failed');
}
/**
* Structured per-query latency log for production aggregation (#553).
* Per-query latency telemetry for production aggregation (#553).
*
* Emitted on stderr NOT stdout because the MCP stdio transport uses
* stdout exclusively for JSON-RPC responses (#324), and the CLI e2e test
* `tool output goes to stdout via fd 1` asserts that stdout parses cleanly
* as JSON. Any `console.log` from inside a tool handler would corrupt the
* protocol. Matches the existing `logQueryError` convention above, which
* uses stderr for the same reason.
* Logged at `debug` level timing is observability/telemetry, not an
* error. Operators wanting per-query timing set `GITNEXUS_LOG_LEVEL=debug`
* (or equivalent). Emitting at `error` level (the original migration
* artifact) caused alerting rules to fire on every successful query and
* inflated stderr noise for every MCP/CLI invocation.
*
* The `GitNexus [query:timing] …` prefix keeps lines greppable; the
* `phases` payload is JSON so log-scraping pipelines can parse it
* without custom format knowledge.
* Emitted via the project logger which routes to stderr never stdout
* because the MCP stdio transport uses stdout exclusively for JSON-RPC
* responses (#324) and the CLI e2e test `tool output goes to stdout via
* fd 1` asserts stdout parses cleanly as JSON.
*/
function logQueryTiming(query: string, phases: Record<string, number>): void {
const totalMs = phases.wall ?? Object.values(phases).reduce((a, b) => a + b, 0);
const truncated = query.length > 80 ? `${query.slice(0, 80)}` : query;
console.error(
`GitNexus [query:timing] query=${JSON.stringify(truncated)} totalMs=${totalMs} phases=${JSON.stringify(phases)}`,
);
logger.debug({ query: truncated, totalMs, phases }, 'GitNexus query timing');
}
export interface CodebaseContext {
@ -287,7 +286,7 @@ export class LocalBackend {
// If kuzu exists but lbug doesn't, warn so the user knows to re-analyze.
const kuzu = await cleanupOldKuzuFiles(storagePath);
if (kuzu.found && kuzu.needsReindex) {
console.error(
logger.error(
`GitNexus: "${entry.name}" has a stale KuzuDB index. Run: gitnexus analyze ${entry.path}`,
);
}
@ -637,7 +636,7 @@ export class LocalBackend {
}
this.warnedSiblingDrift.add(cacheKey);
console.error(`GitNexus: ${match.hint}`);
logger.error(`GitNexus: ${match.hint}`);
}
// ─── Tool Dispatch ───────────────────────────────────────────────
@ -990,7 +989,10 @@ export class LocalBackend {
try {
bm25Results = await searchFTSFromLbug(query, limit, repo.id);
} catch (err: any) {
console.error('GitNexus: BM25/FTS search failed (FTS indexes may not exist) -', err.message);
logger.error(
{ err: err.message },
'GitNexus: BM25/FTS search failed (FTS indexes may not exist) -',
);
return { results: [], ftsUsed: false };
}
@ -1114,7 +1116,7 @@ export class LocalBackend {
// policy. Emitted once per `LocalBackend` instance lifetime to avoid
// noisy stderr on hot semantic-search paths (DoD §2.8).
this.warnedVectorUnsupported = true;
console.error(
logger.warn(
'GitNexus [query:vector]: VECTOR extension not supported on this platform; using exact scan fallback',
);
}

View file

@ -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,20 +287,36 @@ 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);
// Graceful shutdown helper
// 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. Pino's default destination is `sync: false`
// (buffered), so we must `flushLoggerSync()` before `process.exit` —
// otherwise records emitted during disconnect/close are lost. The flush
// is a no-op when the singleton was never used or when running under
// vitest. See `gitnexus/src/core/logger.ts`.
let shuttingDown = false;
const shutdown = async (exitCode = 0) => {
if (shuttingDown) return;
@ -311,6 +327,8 @@ export async function startMCPServer(backend: LocalBackend): Promise<void> {
try {
await server.close();
} catch {}
const { flushLoggerSync } = await import('../core/logger.js');
flushLoggerSync();
process.exit(exitCode);
};

View file

@ -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;
}

View file

@ -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;
}

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