diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index ab8d8362c..03908bb2b 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -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 } diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index eb6b08207..d1dda8fb0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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 }}' diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 5b598ee14..9ab00c0bd 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -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: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index b132b7c3f..c386f7aa5 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -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 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 5bc5d17a4..f476ee7bc 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -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 }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 3dd492406..97f388d28 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -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 diff --git a/README.md b/README.md index 2dad7f2ef..f5f3c5a88 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yaml b/docker-compose.yaml index 297eab85e..6d2176c97 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/docs/guides/microservices-thrift.md b/docs/guides/microservices-thrift.md new file mode 100644 index 000000000..7b37680aa --- /dev/null +++ b/docs/guides/microservices-thrift.md @@ -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::./`. +- Service wildcard ids in the form `thrift::./*` 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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5b365ba67..f377cba6c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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, ]; diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index e5fccc412..7fcc0d299 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -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": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index d03509f4f..81d7fc5fa 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -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", diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 180c61fec..b07ccd7cb 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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", diff --git a/gitnexus/package.json b/gitnexus/package.json index bd4cbe12c..84f702762 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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", diff --git a/gitnexus/scripts/build-tree-sitter-dart.cjs b/gitnexus/scripts/build-tree-sitter-dart.cjs index 60da9a751..3c56e9f0c 100644 --- a/gitnexus/scripts/build-tree-sitter-dart.cjs +++ b/gitnexus/scripts/build-tree-sitter-dart.cjs @@ -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'); diff --git a/gitnexus/scripts/build-tree-sitter-proto.cjs b/gitnexus/scripts/build-tree-sitter-proto.cjs index 0690dfca3..47e091daf 100644 --- a/gitnexus/scripts/build-tree-sitter-proto.cjs +++ b/gitnexus/scripts/build-tree-sitter-proto.cjs @@ -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'); diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 1b8396301..41ba6c73e 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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}:`); } } diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 2c100228d..175db47e6 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 ` uses `` 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(); + 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 `); - 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 \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; diff --git a/gitnexus/src/cli/clean.ts b/gitnexus/src/cli/clean.ts index 4681508fb..2bbc32aef 100644 --- a/gitnexus/src/cli/clean.ts +++ b/gitnexus/src/cli/clean.ts @@ -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:'); } }; diff --git a/gitnexus/src/cli/cli-message.ts b/gitnexus/src/cli/cli-message.ts new file mode 100644 index 000000000..db5f51fab --- /dev/null +++ b/gitnexus/src/cli/cli-message.ts @@ -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): 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): 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): void { + writeStderr(msg); + logger.error(fields ?? {}, msg); +} diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index cf9013d63..0819c33e8 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -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 r.name).join(', ')}`, + logger.info( + { repoCount: repos.length, repos: repos.map((r) => r.name) }, + 'GitNexus eval-server: repos loaded', ); let idleTimer: ReturnType | null = null; @@ -347,7 +355,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise { - 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 { - 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 { - console.error('GitNexus eval-server: shutting down...'); + logger.info('GitNexus eval-server: shutting down...'); await backend.disconnect(); server.close(); process.exit(0); diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index eb0dffc3d..0053099b7 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -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; } diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index ca536dc80..b89b40db0 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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` ' + diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index 58c7f10f0..056b12591 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -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', ); } diff --git a/gitnexus/src/cli/optional-grammars.ts b/gitnexus/src/cli/optional-grammars.ts new file mode 100644 index 000000000..14f6c3c5e --- /dev/null +++ b/gitnexus/src/cli/optional-grammars.ts @@ -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; +}): 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 }, + ); + } +} diff --git a/gitnexus/src/cli/remove.ts b/gitnexus/src/cli/remove.ts index 4d2ce0771..18d2a340d 100644 --- a/gitnexus/src/cli/remove.ts +++ b/gitnexus/src/cli/remove.ts @@ -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); } }; diff --git a/gitnexus/src/cli/serve.ts b/gitnexus/src/cli/serve.ts index 9f0379306..9356b5bab 100644 --- a/gitnexus/src/cli/serve.ts +++ b/gitnexus/src/cli/serve.ts @@ -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); } }; diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index b301d7f38..d1b7b520f 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -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@` (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 { 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) { diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 443f12f4c..b40ffdd25 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -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 { _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 { if (!queryText?.trim()) { - console.error('Usage: gitnexus query '); + cliError('Usage: gitnexus query '); process.exit(1); } @@ -93,7 +94,7 @@ export async function contextCommand( }, ): Promise { if (!name?.trim() && !options?.uid) { - console.error('Usage: gitnexus context [--uid ] [--file ]'); + cliError('Usage: gitnexus context [--uid ] [--file ]'); process.exit(1); } @@ -118,7 +119,7 @@ export async function impactCommand( }, ): Promise { if (!target?.trim()) { - console.error('Usage: gitnexus impact [--direction upstream|downstream]'); + cliError('Usage: gitnexus impact [--direction upstream|downstream]'); process.exit(1); } @@ -153,7 +154,7 @@ export async function cypherCommand( }, ): Promise { if (!query?.trim()) { - console.error('Usage: gitnexus cypher '); + cliError('Usage: gitnexus cypher '); process.exit(1); } diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index ccd1cae4e..e44566f8f 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -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; diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index ff61f0eb3..ce1fda913 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -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}`); } } } diff --git a/gitnexus/src/core/embedding-mode.ts b/gitnexus/src/core/embedding-mode.ts index 7c5b5cf02..1e603f4e3 100644 --- a/gitnexus/src/core/embedding-mode.ts +++ b/gitnexus/src/core/embedding-mode.ts @@ -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, diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index 0d7fe41df..b37fb45f3 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -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]) { diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index 82af563d4..cc2d38d9b 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -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({ diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 76eb5c0f7..dbf2350bb 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -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.` 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 { } } +/** + * 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 { + 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 { 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 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; } diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index 4703e6329..73a9021b9 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -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, diff --git a/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts index 33afcaed9..7c184ba96 100644 --- a/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/elixir-workspace-extractor.ts @@ -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; diff --git a/gitnexus/src/core/group/extractors/go-workspace-extractor.ts b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts index fbdf0e450..55e870090 100644 --- a/gitnexus/src/core/group/extractors/go-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/go-workspace-extractor.ts @@ -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; diff --git a/gitnexus/src/core/group/extractors/grpc-extractor.ts b/gitnexus/src/core/group/extractors/grpc-extractor.ts index b5782d9b3..c08ba7c36 100644 --- a/gitnexus/src/core/group/extractors/grpc-extractor.ts +++ b/gitnexus/src/core/group/extractors/grpc-extractor.ts @@ -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; diff --git a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts index 66ee35ee5..b6beed71c 100644 --- a/gitnexus/src/core/group/extractors/java-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/java-workspace-extractor.ts @@ -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; diff --git a/gitnexus/src/core/group/extractors/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 3185f05e1..2af3db595 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -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': diff --git a/gitnexus/src/core/group/extractors/node-workspace-extractor.ts b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts index 05a7c95dd..aa40ac088 100644 --- a/gitnexus/src/core/group/extractors/node-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/node-workspace-extractor.ts @@ -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; diff --git a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts index 5930808af..4453852a6 100644 --- a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts @@ -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; diff --git a/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts index c19af07ca..63fe7ea82 100644 --- a/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/rust-workspace-extractor.ts @@ -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; diff --git a/gitnexus/src/core/group/extractors/thrift-extractor.ts b/gitnexus/src/core/group/extractors/thrift-extractor.ts new file mode 100644 index 000000000..cfd8fef02 --- /dev/null +++ b/gitnexus/src/core/group/extractors/thrift-extractor.ts @@ -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; + servicesByName: Map; +} + +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(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, +): 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 { + const thriftFiles = await glob('**/*.thrift', { + cwd: repoPath, + absolute: false, + nodir: true, + ignore: ['**/node_modules/**', '**/.git/**', '**/vendor/**', '**/dist/**', '**/build/**'], + }); + const namespacesByThrift = new Map(); + const servicesByName = new Map(); + + 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 { + return true; + } + + async extract( + _dbExecutor: CypherExecutor | null, + repoPath: string, + _repo: RepoHandle, + ): Promise { + 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(); + 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()); + } +} diff --git a/gitnexus/src/core/group/extractors/thrift-patterns/index.ts b/gitnexus/src/core/group/extractors/thrift-patterns/index.ts new file mode 100644 index 000000000..2df229bfa --- /dev/null +++ b/gitnexus/src/core/group/extractors/thrift-patterns/index.ts @@ -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 = { + '.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]; +} diff --git a/gitnexus/src/core/group/extractors/thrift-patterns/java.ts b/gitnexus/src/core/group/extractors/thrift-patterns/java.ts new file mode 100644 index 000000000..cc067479a --- /dev/null +++ b/gitnexus/src/core/group/extractors/thrift-patterns/java.ts @@ -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>); + +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>); + +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>); + +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): 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(); + 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; + }, +}; diff --git a/gitnexus/src/core/group/extractors/thrift-patterns/types.ts b/gitnexus/src/core/group/extractors/thrift-patterns/types.ts new file mode 100644 index 000000000..e1550efca --- /dev/null +++ b/gitnexus/src/core/group/extractors/thrift-patterns/types.ts @@ -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[]; +} diff --git a/gitnexus/src/core/group/matching.ts b/gitnexus/src/core/group/matching.ts index 07f88a61a..3431f8ddf 100644 --- a/gitnexus/src/core/group/matching.ts +++ b/gitnexus/src/core/group/matching.ts @@ -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::[/]`. + case 'grpc': + case 'thrift': { + // Canonical form: `::[/]`. // // 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 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, ): 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(); 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 "::" and "/*" + const candidateProviders: StoredContract[] = []; + const matchedProviderServices = new Set(); 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 "::" 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}`); }); diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index a412ceaa8..de324e70b 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -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'); } } } diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index 5380b9867..99bf27fbd 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -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 { 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); diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 09b289033..7ed065131 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -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) @@ -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(); 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, }; diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index 895bef6dc..7d0a14251 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/ast-cache.ts b/gitnexus/src/core/ingestion/ast-cache.ts index 65da46ab8..454c60df2 100644 --- a/gitnexus/src/core/ingestion/ast-cache.ts +++ b/gitnexus/src/core/ingestion/ast-cache.ts @@ -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'); } }, }); diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 1871d0605..6c59578b0 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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>; @@ -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.`, ); } diff --git a/gitnexus/src/core/ingestion/cluster-enricher.ts b/gitnexus/src/core/ingestion/cluster-enricher.ts index b20ed2bad..06cd4d0cd 100644 --- a/gitnexus/src/core/ingestion/cluster-enricher.ts +++ b/gitnexus/src/core/ingestion/cluster-enricher.ts @@ -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, { diff --git a/gitnexus/src/core/ingestion/cobol/cobol-copy-expander.ts b/gitnexus/src/core/ingestion/cobol/cobol-copy-expander.ts index 46a33001d..dda135b12 100644 --- a/gitnexus/src/core/ingestion/cobol/cobol-copy-expander.ts +++ b/gitnexus/src/core/ingestion/cobol/cobol-copy-expander.ts @@ -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.`, ); diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index 71a4046f2..4d6725e24 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -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}`); } } } diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 12e59a19a..2c973ad8e 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -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.`, ); } diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index b669d2744..03cbed5b7 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -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> // Stores all files that a given file imports from export type ImportMap = Map>; @@ -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`, ); } diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 682d7b190..f51ef57c6 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -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 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 0) { if (isDev) { - console.log(`📦 Loaded ${targets.size} Swift package targets`); + logger.info(`📦 Loaded ${targets.size} Swift package targets`); } return { targets }; } diff --git a/gitnexus/src/core/ingestion/languages/go/range-binding.ts b/gitnexus/src/core/ingestion/languages/go/range-binding.ts index d95676480..14e520c2d 100644 --- a/gitnexus/src/core/ingestion/languages/go/range-binding.ts +++ b/gitnexus/src/core/ingestion/languages/go/range-binding.ts @@ -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), diff --git a/gitnexus/src/core/ingestion/languages/go/type-binding.ts b/gitnexus/src/core/ingestion/languages/go/type-binding.ts index c0808a990..2db737930 100644 --- a/gitnexus/src/core/ingestion/languages/go/type-binding.ts +++ b/gitnexus/src/core/ingestion/languages/go/type-binding.ts @@ -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), diff --git a/gitnexus/src/core/ingestion/method-extractors/generic.ts b/gitnexus/src/core/ingestion/method-extractors/generic.ts index 7e15f2458..f02faef35 100644 --- a/gitnexus/src/core/ingestion/method-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/method-extractors/generic.ts @@ -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): 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}'`, ); } diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 86b419990..8803ec023 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/cobol.ts b/gitnexus/src/core/ingestion/pipeline-phases/cobol.ts index cfe6b6ce2..c9332aabc 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/cobol.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/cobol.ts @@ -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 = { 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 = { 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`); } } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/communities.ts b/gitnexus/src/core/ingestion/pipeline-phases/communities.ts index 6a302b8b9..0e29b6cc2 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/communities.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/communities.ts @@ -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 = { }); if (isDev) { - console.log( + logger.info( `🏘️ Community detection: ${communityResult.stats.totalCommunities} communities found (modularity: ${communityResult.stats.modularity.toFixed(3)})`, ); } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts index 334ff57df..5c014ed73 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/cross-file-impl.ts @@ -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)`, ); diff --git a/gitnexus/src/core/ingestion/pipeline-phases/cross-file.ts b/gitnexus/src/core/ingestion/pipeline-phases/cross-file.ts index e1e907a0b..3ea604b4d 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/cross-file.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/cross-file.ts @@ -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 = { 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.`, ); } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/markdown.ts b/gitnexus/src/core/ingestion/pipeline-phases/markdown.ts index 6b3853b9d..dd57518c1 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/markdown.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/markdown.ts @@ -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 = { 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`, ); } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/mro.ts b/gitnexus/src/core/ingestion/pipeline-phases/mro.ts index 372ae32b0..c098f2b7b 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/mro.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/mro.ts @@ -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 = { 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`, ); } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/orm.ts b/gitnexus/src/core/ingestion/pipeline-phases/orm.ts index ebdac018a..4e6021efa 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/orm.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/orm.ts @@ -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)`, ); } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index 025bdbeb7..bd39a4330 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -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)`, ); } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index 209906cfb..166faea20 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -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 = { ); 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 = { } } if (isDev && linked > 0) { - console.log(`🔗 Linked ${linked} Route/Tool nodes to execution flows`); + logger.info(`🔗 Linked ${linked} Route/Tool nodes to execution flows`); } } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index cd0a65f9d..de3a8ddb8 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -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, /]*href=\s*['"`]([^'"`]+)['"`]/g, @@ -174,7 +175,7 @@ export const routesPhase: PipelinePhase = { } 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 = { 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 = { processNextjsFetchRoutes(ctx.graph, allFetchCalls, routeURLToFile, consumerContents); if (isDev) { - console.log( + logger.info( `🔗 Processed ${allFetchCalls.length} fetch() calls against ${routeRegistry.size} routes`, ); } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/runner.ts b/gitnexus/src/core/ingestion/pipeline-phases/runner.ts index 89543e049..0bfc45bd4 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/runner.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/runner.ts @@ -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)`); } } diff --git a/gitnexus/src/core/ingestion/pipeline-phases/tools.ts b/gitnexus/src/core/ingestion/pipeline-phases/tools.ts index 023c8a1af..32a0ae71f 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/tools.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/tools.ts @@ -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 = { } if (isDev) { - console.log(`🔧 Tool registry: ${toolDefs.length} tools detected`); + logger.info(`🔧 Tool registry: ${toolDefs.length} tools detected`); } } diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index a12378c98..aa744e54d 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -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(' × ')}]`); }); } diff --git a/gitnexus/src/core/ingestion/scope-extractor-bridge.ts b/gitnexus/src/core/ingestion/scope-extractor-bridge.ts index 1774cefca..41a50193d 100644 --- a/gitnexus/src/core/ingestion/scope-extractor-bridge.ts +++ b/gitnexus/src/core/ingestion/scope-extractor-bridge.ts @@ -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; } } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index 5dbb3715f..c2fda9777 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -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 = { 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 = { }); 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)`, ); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index b069f0dd1..558c9ef30 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -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` + diff --git a/gitnexus/src/core/ingestion/type-env.ts b/gitnexus/src/core/ingestion/type-env.ts index 998e7a59e..a38df617b 100644 --- a/gitnexus/src/core/ingestion/type-env.ts +++ b/gitnexus/src/core/ingestion/type-env.ts @@ -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`, ); } diff --git a/gitnexus/src/core/ingestion/utils/max-file-size.ts b/gitnexus/src/core/ingestion/utils/max-file-size.ts index 0c418bfd4..82013783a 100644 --- a/gitnexus/src/core/ingestion/utils/max-file-size.ts +++ b/gitnexus/src/core/ingestion/utils/max-file-size.ts @@ -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(); const warnOnce = (key: string, message: string): void => { if (warned.has(key)) return; warned.add(key); - console.warn(message); + logger.warn(message); }; /** diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 669465fe2..5c6712562 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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, ); diff --git a/gitnexus/src/core/ingestion/workers/worker-pool.ts b/gitnexus/src/core/ingestion/workers/worker-pool.ts index 4be6af6b1..211368567 100644 --- a/gitnexus/src/core/ingestion/workers/worker-pool.ts +++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts @@ -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 { + return new Promise((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(); diff --git a/gitnexus/src/core/lbug/extension-loader.ts b/gitnexus/src/core/lbug/extension-loader.ts index 9fbee871b..582541942 100644 --- a/gitnexus/src/core/lbug/extension-loader.ts +++ b/gitnexus/src/core/lbug/extension-loader.ts @@ -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); diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index bdacadff9..2fc12cf96 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -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 (dbPath: string, operation: () => Promise) // 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 => { +/** + * 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 => { + 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 => { + 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 => { + await safeClose(); currentDbPath = null; ftsLoaded = false; vectorExtensionLoaded = false; diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index 7640092b4..ca1c45611 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -84,9 +84,21 @@ const MAX_CONNS_PER_REPO = 8; let idleTimer: ReturnType | 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(); diff --git a/gitnexus/src/core/logger.ts b/gitnexus/src/core/logger.ts new file mode 100644 index 000000000..3fd39193b --- /dev/null +++ b/gitnexus/src/core/logger.ts @@ -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 | 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` 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; + }, + }; +} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index e754a0242..7227fd945 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 = { 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 \` to set a custom cap.`, + ); } } diff --git a/gitnexus/src/core/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index e79cdc599..8057e7795 100644 --- a/gitnexus/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus/src/core/tree-sitter/parser-loader.ts @@ -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 => diff --git a/gitnexus/src/core/wiki/cursor-client.ts b/gitnexus/src/core/wiki/cursor-client.ts index 707f8e293..bf85f4183 100644 --- a/gitnexus/src/core/wiki/cursor-client.ts +++ b/gitnexus/src/core/wiki/cursor-client.ts @@ -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]'); } } diff --git a/gitnexus/src/core/wiki/llm-client.ts b/gitnexus/src/core/wiki/llm-client.ts index 6f446be15..f68c2d3e7 100644 --- a/gitnexus/src/core/wiki/llm-client.ts +++ b/gitnexus/src/core/wiki/llm-client.ts @@ -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.', ); } diff --git a/gitnexus/src/mcp/compatible-stdio-transport.ts b/gitnexus/src/mcp/compatible-stdio-transport.ts index 59b41e193..b5fc85630 100644 --- a/gitnexus/src/mcp/compatible-stdio-transport.ts +++ b/gitnexus/src/mcp/compatible-stdio-transport.ts @@ -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 { diff --git a/gitnexus/src/mcp/core/embedder.ts b/gitnexus/src/mcp/core/embedder.ts index f506cdead..ff3a12fd8 100644 --- a/gitnexus/src/mcp/core/embedder.ts +++ b/gitnexus/src/mcp/core/embedder.ts @@ -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 => { 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 => { 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'); diff --git a/gitnexus/src/mcp/core/lbug-adapter.ts b/gitnexus/src/mcp/core/lbug-adapter.ts index ffbd394f5..f2521284e 100644 --- a/gitnexus/src/mcp/core/lbug-adapter.ts +++ b/gitnexus/src/mcp/core/lbug-adapter.ts @@ -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'; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 68df9feaa..11f7e61f3 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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): 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', ); } diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index be80f4c35..5159b12d9 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -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 { 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 { try { await server.close(); } catch {} + const { flushLoggerSync } = await import('../core/logger.js'); + flushLoggerSync(); process.exit(exitCode); }; diff --git a/gitnexus/src/mcp/stdio-capture.ts b/gitnexus/src/mcp/stdio-capture.ts new file mode 100644 index 000000000..41b553015 --- /dev/null +++ b/gitnexus/src/mcp/stdio-capture.ts @@ -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; +} diff --git a/gitnexus/src/mcp/stdio-context.ts b/gitnexus/src/mcp/stdio-context.ts new file mode 100644 index 000000000..93f508bb5 --- /dev/null +++ b/gitnexus/src/mcp/stdio-context.ts @@ -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(); + +export function withMcpWrite(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; +} diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index a50de5d33..96ee9a5dc 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -19,6 +19,7 @@ import { executePrepared, executeWithReusedStatement, streamQuery, + flushWAL, closeLbug, withLbugDb, } from '../core/lbug/lbug-adapter.js'; @@ -26,14 +27,13 @@ import { isWriteQuery } from '../core/lbug/pool-adapter.js'; import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-shared'; import { searchFTSFromLbug } from '../core/search/bm25-index.js'; import { hybridSearch } from '../core/search/hybrid-search.js'; -// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node -// at server startup — crashes on unsupported Node ABI versions (#89) import { LocalBackend } from '../mcp/local/local-backend.js'; import { fork } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; import { JobManager } from './analyze-job.js'; import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js'; import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js'; +import { logger, flushLoggerSync } from '../core/logger.js'; const _require = createRequire(import.meta.url); const pkg = _require('../../package.json'); @@ -145,7 +145,7 @@ export const resolveWebDistDir = async ( return dir; } catch (err: any) { if (err?.code !== 'ENOENT') { - console.warn(`[serve] could not access web UI dir ${dir}:`, err.message); + logger.warn({ err: err.message }, `[serve] could not access web UI dir ${dir}:`); } } } @@ -186,6 +186,7 @@ a.ext:hover{text-decoration:underline}
Endpoints

/api/info — Server version & context

/api/repos — Indexed repositories

+

/api/health — Docker/orchestrator healthcheck

/api/heartbeat — SSE heartbeat

/api/graph /api/query /api/search — Data

/api/mcp — MCP over StreamableHTTP

@@ -788,6 +789,13 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => return found; }; + // Lightweight healthcheck for Docker/orchestrator probes (#1147). + // Returns immediately so container managers do not confuse a long-lived + // SSE stream with an unhealthy server. + app.get('/api/health', (_req, res) => { + res.json({ status: 'ok' }); + }); + // SSE heartbeat — clients connect to detect server liveness instantly. // When the server shuts down, the TCP connection drops and the client's // EventSource fires onerror immediately (no polling delay). @@ -1492,7 +1500,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => }); }) .catch((err) => { - console.error('backend.init() failed after analyze:', err); + logger.error({ err }, 'backend.init() failed after analyze:'); jobManager.updateJob(job.id, { status: 'failed', error: 'Server failed to reload after analysis. Try again.', @@ -1524,7 +1532,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => j.retryCount++; const delay = 1000 * Math.pow(2, j.retryCount - 1); // 1s, 2s const lastErr = stderrChunks.trim().split('\n').pop() || ''; - console.warn( + logger.warn( `Analyze worker crashed (code ${code}), retry ${j.retryCount}/${MAX_WORKER_RETRIES} in ${delay}ms` + (lastErr ? `: ${lastErr}` : ''), ); @@ -1705,6 +1713,12 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => undefined, // context existingEmbeddings, ); + + // Flush WAL so subsequent /api/search requests see the new + // embeddings immediately (#1149). In the CLI path closeLbug() + // handles this during process exit, but the server keeps the + // connection open for other routes — a CHECKPOINT is enough. + await flushWAL(); }); clearTimeout(embedTimeout); @@ -1786,7 +1800,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Global error handler — catch anything the route handlers miss app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error('Unhandled error:', err); + logger.error({ err }, 'Unhandled error:'); res.status(500).json({ error: 'Internal server error' }); }); @@ -1800,7 +1814,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => }); server.on('error', (err) => reject(err)); - // Graceful shutdown — close Express + LadybugDB cleanly + // Graceful shutdown — close Express + LadybugDB cleanly. Pino's default + // destination is `sync: false` (buffered); `flushLoggerSync()` before + // `process.exit` so records emitted during cleanup reach stderr. const shutdown = async () => { console.log('\nShutting down...'); server.close(); @@ -1809,22 +1825,33 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => await cleanupMcp(); await closeLbug(); await backend.disconnect(); + const { flushLoggerSync } = await import('../core/logger.js'); + flushLoggerSync(); process.exit(0); }; process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); - // Catch-all crash guards (mirrors startMCPServer in mcp/server.ts) + // Catch-all crash guards (mirrors startMCPServer in mcp/server.ts). + // Pino v10's default destination is buffered (`sync: false`) — call + // `flushLoggerSync()` after logging and before triggering shutdown + // so the crash record reaches stderr regardless of how cleanup goes. + // Worker-thread transports (pino-pretty under TTY) handle their own + // flush on process exit in v10. `pino.final` was removed in v10 + // because the new transport architecture made it unnecessary. let shuttingDown = false; process.on('uncaughtException', (err) => { - console.error('GitNexus uncaughtException:', err?.stack || err); + logger.error({ err }, 'GitNexus uncaughtException'); + flushLoggerSync(); if (!shuttingDown) { shuttingDown = true; shutdown().catch(() => {}); } }); - process.on('unhandledRejection', (reason: any) => { - console.error('GitNexus unhandledRejection:', reason?.stack || reason); + process.on('unhandledRejection', (reason: unknown) => { + // Availability-first: log the rejection without exiting. + const err = reason instanceof Error ? reason : new Error(String(reason)); + logger.error({ err }, 'GitNexus unhandledRejection'); }); }); }; diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index b1f369dd7..861a16648 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -10,6 +10,7 @@ import path from 'path'; import os from 'os'; import fs from 'fs/promises'; import { isIP } from 'net'; +import { logger } from '../core/logger.js'; /** Root directory for all cloned repositories. Targets must resolve inside this. */ const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); @@ -446,7 +447,7 @@ function runGit(args: string[], cwd?: string): Promise { if (code === 0) resolve(); else { // Log full stderr internally but don't expose it to API callers (SSRF mitigation) - if (stderr.trim()) console.error(`git ${args[0]} stderr: ${stderr.trim()}`); + if (stderr.trim()) logger.error(`git ${args[0]} stderr: ${stderr.trim()}`); reject(new Error(`git ${args[0]} failed (exit code ${code})`)); } }); diff --git a/gitnexus/src/server/mcp-http.ts b/gitnexus/src/server/mcp-http.ts index 0bc086360..67ec94708 100644 --- a/gitnexus/src/server/mcp-http.ts +++ b/gitnexus/src/server/mcp-http.ts @@ -15,6 +15,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { createMCPServer } from '../mcp/server.js'; import type { LocalBackend } from '../mcp/local/local-backend.js'; import { randomUUID } from 'crypto'; +import { logger } from '../core/logger.js'; interface MCPSession { server: Server; @@ -87,7 +88,7 @@ export function mountMCPEndpoints(app: Express, backend: LocalBackend): () => Pr app.all('/api/mcp', (req: Request, res: Response) => { void handleMcpRequest(req, res).catch((err: any) => { - console.error('MCP HTTP request failed:', err); + logger.error({ err }, 'MCP HTTP request failed:'); if (res.headersSent) return; res.status(500).json({ jsonrpc: '2.0', diff --git a/gitnexus/src/server/validation.ts b/gitnexus/src/server/validation.ts index 83fa6922a..bae6a9ad0 100644 --- a/gitnexus/src/server/validation.ts +++ b/gitnexus/src/server/validation.ts @@ -19,7 +19,7 @@ */ import path from 'node:path'; -import rateLimit, { ipKeyGenerator, type RateLimitRequestHandler } from 'express-rate-limit'; +import rateLimit, { type RateLimitRequestHandler, ipKeyGenerator } from 'express-rate-limit'; import type { Request } from 'express'; /** @@ -138,6 +138,9 @@ export interface RouteLimiterOverrides { * - keyGenerator: req.ip with a socket.remoteAddress fallback so abruptly * closed connections do not trigger ERR_ERL_UNDEFINED_IP_ADDRESS * (which would 500 the request via Express's default error handler). + * The IP is passed through `ipKeyGenerator` so IPv6 addresses are + * normalised to their /56 subnet — without this, each IPv6 address + * gets its own counter and the limit is trivially bypassed (#1360). * Caller must wire `app.set('trust proxy', ...)` correctly — see * createServer in api.ts. * @@ -151,7 +154,10 @@ export function createRouteLimiter(opts?: RouteLimiterOverrides): RateLimitReque standardHeaders: 'draft-7', legacyHeaders: false, passOnStoreError: true, - keyGenerator: (req: Request) => ipKeyGenerator(req.ip ?? req.socket?.remoteAddress ?? ''), + keyGenerator: (req: Request) => { + const ip = req.ip ?? req.socket?.remoteAddress; + return ip ? ipKeyGenerator(ip) : 'unknown'; + }, message: { error: 'Too many requests, please try again later.' }, ...opts, }); diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index fe1b8511d..4cb0154fe 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -15,7 +15,17 @@ export const isGitRepo = (repoPath: string): boolean => { export const getCurrentCommit = (repoPath: string): string => { try { - return execSync('git rev-parse HEAD', { cwd: repoPath }).toString().trim(); + return execSync('git rev-parse HEAD', { + cwd: repoPath, + // Suppress stderr -- without an explicit stdio option, Node's execSync + // forwards the child's stderr to the parent process (documented behaviour). + // When repoPath is not inside a git worktree, git prints + // "fatal: not a git repository" to stderr, which leaks to the user's + // terminal even though the error is caught here (#1172). + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim(); } catch { return ''; } @@ -86,7 +96,13 @@ export const getRemoteUrl = (repoPath: string): string | undefined => { */ export const getGitRoot = (fromPath: string): string | null => { try { - const raw = execSync('git rev-parse --show-toplevel', { cwd: fromPath }).toString().trim(); + const raw = execSync('git rev-parse --show-toplevel', { + cwd: fromPath, + // Suppress stderr -- see getCurrentCommit comment and #1172. + stdio: ['ignore', 'pipe', 'ignore'], + }) + .toString() + .trim(); // On Windows, git returns /d/Projects/Foo — path.resolve normalizes to D:\Projects\Foo return path.resolve(raw); } catch { diff --git a/gitnexus/test/helpers/test-indexed-db.ts b/gitnexus/test/helpers/test-indexed-db.ts index d0eda43f0..7ddf28a16 100644 --- a/gitnexus/test/helpers/test-indexed-db.ts +++ b/gitnexus/test/helpers/test-indexed-db.ts @@ -115,6 +115,12 @@ export function withTestLbugDB( } } + // 5b. Flush WAL so seed data + FTS indexes are visible to the pool + // adapter's read path. Without this, Windows CI intermittently + // fails FTS queries because the WAL hasn't been checkpointed + // before the pool adapter starts reading. + await adapter.flushWAL(); + // 6. Open pool adapter by injecting the core adapter's writable Database. // LadybugDB enforces file locks — writable + read-only can't coexist // on the same path, and db.close() segfaults on macOS due to N-API diff --git a/gitnexus/test/integration/cli/tool-no-index-stderr.test.ts b/gitnexus/test/integration/cli/tool-no-index-stderr.test.ts new file mode 100644 index 000000000..baf8178fd --- /dev/null +++ b/gitnexus/test/integration/cli/tool-no-index-stderr.test.ts @@ -0,0 +1,122 @@ +/** + * Regression test for the buffered-pino + hard-exit diagnostic-loss bug + * (Codex adversarial review on PR #1336, plan 002). + * + * Symptom before the fix: `gitnexus tool query ` with no indexed + * repos exits non-zero with EMPTY stderr — the `logger.error()` call was + * routed through pino's `sync: false` SonicBoom buffer, and the + * subsequent synchronous `process.exit(1)` killed the process before the + * buffer could drain. Operators saw a silent failure. + * + * The fix routes user-facing CLI diagnostics through `cliError` (in + * `gitnexus/src/cli/cli-message.ts`), which writes plain text directly + * to `process.stderr` AND tees a structured pino record. Direct stderr + * writes don't go through the buffer, so they survive `process.exit`. + * + * This test spawns the built CLI in a child process and asserts the + * diagnostic line reaches stderr before exit. Without the fix it fails; + * with the fix it passes. Characterization-first contract, locked in + * end-to-end against `dist/`. + */ +import { describe, it, expect } from 'vitest'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); +const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js'); + +const CHILD_TIMEOUT_MS = process.env.CI ? 20_000 : 10_000; + +interface ChildResult { + exitCode: number | null; + stdout: string; + stderr: string; +} + +/** + * Spawn the built `gitnexus` CLI with arguments, wait for exit, and + * return captured streams + exit code. Pin GITNEXUS_HOME to a fresh + * empty temp dir so the LocalBackend init reliably finds zero indexed + * repos. Force NODE_OPTIONS empty to prevent host-environment overrides + * from changing buffer / heap behavior (plan 001 U3 added the buffered + * destination, which is what this test guards against). + */ +function runCli(args: string[]): Promise { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-cli-no-index-')); + return new Promise((resolve, reject) => { + const proc = spawn(process.execPath, [DIST_CLI, ...args], { + cwd: REPO_ROOT, + env: { + ...process.env, + GITNEXUS_HOME: tmpHome, + NODE_OPTIONS: '', + // Force NDJSON path: pino-pretty only activates when stderr is a + // TTY and !CI && !VITEST. spawn() pipes stderr, so it's not a + // TTY in this child anyway, but the explicit env is defense-in-depth. + CI: '1', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + proc.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); + proc.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); + + const timer = setTimeout(() => { + proc.kill('SIGKILL'); + reject(new Error(`child process exceeded ${CHILD_TIMEOUT_MS}ms timeout`)); + }, CHILD_TIMEOUT_MS); + + proc.on('close', (code) => { + clearTimeout(timer); + // Best-effort cleanup of the empty temp home; ignore errors so they + // don't mask test failures. + try { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } + resolve({ + exitCode: code, + stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stderr: Buffer.concat(stderrChunks).toString('utf8'), + }); + }); + proc.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +describe('CLI tool query — diagnostic survives hard exit (plan 002)', () => { + it('emits the no-index diagnostic to stderr before exit code 1', async () => { + if (!fs.existsSync(DIST_CLI)) { + throw new Error( + `dist/cli/index.js missing — run \`npm run build\` first (or use \`npm run test:integration\` which builds via pretest:integration).`, + ); + } + + const result = await runCli(['query', 'whatever']); + + // Without the plan-002 fix, stderr was empty. The diagnostic must be + // visible regardless of how `process.exit(1)` interacts with the + // buffered pino destination. + expect(result.stderr).toContain('No indexed repositories found'); + expect(result.stderr).toContain('gitnexus analyze'); + + // Exit code stays 1 — we're only changing the message channel, not + // the failure semantics. + expect(result.exitCode).toBe(1); + + // Stdout should not carry the diagnostic. CLI tool data is reserved + // for stdout (e.g., `gitnexus query | jq`); diagnostics are stderr. + expect(result.stdout).not.toContain('No indexed repositories found'); + }, 30_000); +}); diff --git a/gitnexus/test/integration/filesystem-walker.test.ts b/gitnexus/test/integration/filesystem-walker.test.ts index accb24e37..2a1257ccc 100644 --- a/gitnexus/test/integration/filesystem-walker.test.ts +++ b/gitnexus/test/integration/filesystem-walker.test.ts @@ -8,6 +8,7 @@ import { } from '../../src/core/ingestion/filesystem-walker.js'; import { _resetMaxFileSizeWarnings } from '../../src/core/ingestion/utils/max-file-size.js'; +import { _captureLogger } from '../../src/core/logger.js'; describe('filesystem-walker', () => { let tmpDir: string; @@ -328,7 +329,7 @@ describe('filesystem-walker', () => { const BIG_FILE = 'src/big.ts'; const BIG_FILE_BYTES = 600 * 1024; const ORIGINAL_ENV = process.env.GITNEXUS_MAX_FILE_SIZE; - let warnSpy: ReturnType; + let cap: ReturnType; beforeAll(async () => { sizeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-size-test-')); @@ -344,7 +345,7 @@ describe('filesystem-walker', () => { beforeEach(() => { delete process.env.GITNEXUS_MAX_FILE_SIZE; _resetMaxFileSizeWarnings(); - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + cap = _captureLogger(); }); afterEach(() => { @@ -353,7 +354,7 @@ describe('filesystem-walker', () => { } else { process.env.GITNEXUS_MAX_FILE_SIZE = ORIGINAL_ENV; } - warnSpy.mockRestore(); + cap.restore(); }); it('skips a 600KB file by default', async () => { @@ -375,27 +376,27 @@ describe('filesystem-walker', () => { const files = await walkRepositoryPaths(sizeDir); const paths = files.map((f) => f.path.replace(/\\/g, '/')); expect(paths).not.toContain(BIG_FILE); - const invalidWarnings = warnSpy.mock.calls.filter((c) => - String(c[0]).includes('must be a positive integer'), - ); + const invalidWarnings = cap + .records() + .filter((r) => String(r.msg ?? '').includes('must be a positive integer')); expect(invalidWarnings).toHaveLength(1); }); it('omits the "generated/vendored" suffix when threshold is overridden', async () => { process.env.GITNEXUS_MAX_FILE_SIZE = '1'; await walkRepositoryPaths(sizeDir); - const skipWarnings = warnSpy.mock.calls.filter((c) => String(c[0]).includes('Skipped ')); + const skipWarnings = cap.records().filter((r) => String(r.msg ?? '').includes('Skipped ')); expect(skipWarnings.length).toBeGreaterThan(0); - for (const call of skipWarnings) { - expect(String(call[0])).not.toContain('generated/vendored'); + for (const r of skipWarnings) { + expect(String(r.msg ?? '')).not.toContain('generated/vendored'); } }); it('keeps the "generated/vendored" suffix under the default threshold', async () => { await walkRepositoryPaths(sizeDir); - const skipWarnings = warnSpy.mock.calls.filter((c) => String(c[0]).includes('Skipped ')); + const skipWarnings = cap.records().filter((r) => String(r.msg ?? '').includes('Skipped ')); expect(skipWarnings.length).toBeGreaterThan(0); - expect(String(skipWarnings[0][0])).toContain('generated/vendored'); + expect(String(skipWarnings[0].msg ?? '')).toContain('generated/vendored'); }); }); }); diff --git a/gitnexus/test/integration/mcp/import-closure.test.ts b/gitnexus/test/integration/mcp/import-closure.test.ts new file mode 100644 index 000000000..e9845ff52 --- /dev/null +++ b/gitnexus/test/integration/mcp/import-closure.test.ts @@ -0,0 +1,111 @@ +/** + * MCP CLI static-import-closure regression test. + * + * Codex's adversarial review on PR #1383 found that even though `cli/mcp.ts` + * is loaded lazily by Commander, ITS static imports (`startMCPServer`, + * `LocalBackend`, `installGlobalStdoutSentinel`, `warnMissingOptionalGrammars`) + * evaluate synchronously when the module loads — well before `mcpCommand`'s + * function body runs. Three of those four imports transitively pull in + * `core/lbug/pool-adapter.ts`, which `import`s `@ladybugdb/core` at module top + * level. The native binding's init can write to raw stdout in that pre-sentinel + * window and corrupt the JSON-RPC frame stream. + * + * This test locks in the fix: spawn a child Node process, import the built + * `dist/cli/mcp.js` (without invoking `mcpCommand`), and assert that + * `@ladybugdb/core` is NOT in the loaded-module set. The assertion is + * evidence-based — it checks Node's CJS module cache, which is global per + * process and tracks every native/CJS module loaded by either ESM or CJS + * importers. + * + * Characterization-first: this test was written before the fix landed and + * MUST fail against the pre-fix code. Run against the parent of the U1 + * commit to verify the regression signal works. + */ + +import { describe, it, expect } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); +const DIST_MCP = path.join(REPO_ROOT, 'dist', 'cli', 'mcp.js'); +const DIST_MCP_URL = pathToFileURL(DIST_MCP).href; + +const PROBE = ` + import { createRequire } from 'node:module'; + const req = createRequire(import.meta.url); + const before = new Set(Object.keys(req.cache)); + await import(process.env.PROBE_TARGET); + const after = new Set(Object.keys(req.cache)); + const newlyLoaded = [...after].filter((k) => !before.has(k)); + process.stdout.write(JSON.stringify(newlyLoaded)); +`; + +describe('MCP CLI static-import closure', () => { + it('does not load @ladybugdb/core when cli/mcp.js is imported (without invoking mcpCommand)', () => { + if (!fs.existsSync(DIST_MCP)) { + throw new Error( + `dist/cli/mcp.js missing — run \`npm run build\` first (or \`npm run test:integration\` which builds via pretest:integration).`, + ); + } + + const result = spawnSync(process.execPath, ['--input-type=module', '-e', PROBE], { + cwd: REPO_ROOT, + env: { ...process.env, PROBE_TARGET: DIST_MCP_URL, NODE_OPTIONS: '' }, + timeout: 30_000, + encoding: 'utf8', + }); + + if (result.status !== 0) { + throw new Error( + `probe failed (status ${result.status}):\nstderr:\n${result.stderr}\nstdout:\n${result.stdout}`, + ); + } + + const newlyLoaded = JSON.parse(result.stdout) as string[]; + + // The headline assertion: @ladybugdb/core (a native CJS module) must not + // be loaded by the static-import closure of cli/mcp.js. If it is, the + // pre-sentinel stdout window the prior fix tried to close is still open. + const ladybugLoaded = newlyLoaded.filter((p) => /@ladybugdb[\\/]core/.test(p)); + expect( + ladybugLoaded, + `@ladybugdb/core was loaded at cli/mcp.js static-import time. ` + + `mcpCommand cannot install the stdout sentinel before native init runs. ` + + `Offending paths:\n${ladybugLoaded.join('\n')}\n\n` + + `Full newly-loaded set (${newlyLoaded.length} entries):\n${newlyLoaded.join('\n')}`, + ).toEqual([]); + }); + + it('does not load any tree-sitter native binding (sanity check on grammar imports)', () => { + if (!fs.existsSync(DIST_MCP)) { + throw new Error(`dist/cli/mcp.js missing — run \`npm run build\` first.`); + } + + const result = spawnSync(process.execPath, ['--input-type=module', '-e', PROBE], { + cwd: REPO_ROOT, + env: { ...process.env, PROBE_TARGET: DIST_MCP_URL, NODE_OPTIONS: '' }, + timeout: 30_000, + encoding: 'utf8', + }); + + if (result.status !== 0) { + throw new Error(`probe failed: ${result.stderr}`); + } + + const newlyLoaded = JSON.parse(result.stdout) as string[]; + // No tree-sitter parser should load at cli/mcp.js static-import time. + // The analyze path is the only caller of warnMissingOptionalGrammars + // (which require()s each grammar); cli/mcp.ts itself does not invoke + // it, and its static-import closure is leaf-only — so importing + // dist/cli/mcp.js without invoking mcpCommand must not trigger any + // native grammar binding load. + const treeSitterNative = newlyLoaded.filter((p) => /tree-sitter-[a-z]+[\\/]build/.test(p)); + expect( + treeSitterNative, + `tree-sitter native bindings loaded at cli/mcp.js static-import time:\n${treeSitterNative.join('\n')}`, + ).toEqual([]); + }); +}); diff --git a/gitnexus/test/integration/mcp/server-startup.test.ts b/gitnexus/test/integration/mcp/server-startup.test.ts new file mode 100644 index 000000000..0660200f6 --- /dev/null +++ b/gitnexus/test/integration/mcp/server-startup.test.ts @@ -0,0 +1,270 @@ +/** + * MCP server end-to-end startup test. + * + * Spawns `node dist/cli/index.js mcp` as a child process, drives the MCP + * stdio handshake (initialize → initialized → tools/list), and asserts: + * + * - The first JSON-RPC frame arrives within a CI-friendly time budget. + * - Every byte the server writes to stdout reassembles into a valid + * Content-Length-framed JSON-RPC message — any stray byte fails the + * test and is surfaced in the assertion message. + * - tools/list reports the GitNexus tool set we expect. + * + * This locks in U1 (no stray console.log/warn in MCP-reachable code) and + * U3 (AsyncLocalStorage stdout sentinel). A regression in either would + * present as either a non-frame byte on stdout (fail-fast) or a missing + * frame (timeout). + * + * Requires the built dist/. Use `npm run test:integration` (which runs + * `npm run build` via the pretest:integration hook) or run after + * `npm run build`. + */ + +import { describe, it, expect } from 'vitest'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); +const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js'); + +const FIRST_FRAME_BUDGET_MS = process.env.CI ? 15_000 : 5_000; +const TOTAL_BUDGET_MS = process.env.CI ? 30_000 : 10_000; + +interface SpawnedServer { + proc: ChildProcessWithoutNullStreams; + stdoutChunks: Buffer[]; + stderrChunks: Buffer[]; + /** Resolves with the next JSON-RPC message parsed from stdout. */ + nextMessage: () => Promise; + /** Bytes received on stdout that did NOT belong to a frame body. */ + strayStdoutBytes: () => Buffer; + send: (message: unknown) => void; + close: () => Promise; +} + +/** + * Spawn the built MCP server and provide a frame-aware reader. + * The reader strictly parses Content-Length framing; any byte outside + * a valid header→body window is captured as "stray" so the test can + * assert the stream is clean. + */ +function spawnMcpServer(): SpawnedServer { + const env: NodeJS.ProcessEnv = { + ...process.env, + // Avoid adding indexed repos noise to the test. + GITNEXUS_HOME: path.join(REPO_ROOT, 'test', 'integration', 'mcp', '.tmp-home'), + // Be deterministic across machines. + NODE_OPTIONS: '', + }; + + const proc = spawn(process.execPath, [DIST_CLI, 'mcp'], { + cwd: REPO_ROOT, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const stray: Buffer[] = []; + const messageQueue: unknown[] = []; + const waiters: Array<(msg: unknown) => void> = []; + + let buffer = Buffer.alloc(0); + // Parser state machine for Content-Length framing. + // 0 = expecting header, 1 = reading body of length `expected`. + let state: 0 | 1 = 0; + let expected = 0; + + const HEADER_END = Buffer.from('\r\n\r\n', 'utf8'); + + function pushMessage(msg: unknown) { + if (waiters.length > 0) { + const w = waiters.shift()!; + w(msg); + } else { + messageQueue.push(msg); + } + } + + function tryParse() { + while (true) { + if (state === 0) { + const hdrEnd = buffer.indexOf(HEADER_END); + if (hdrEnd === -1) return; + const header = buffer.subarray(0, hdrEnd).toString('utf8'); + // Anything before the Content-Length: line (e.g. random bytes) is stray. + // Strict: the header MUST start with "Content-Length:" (case-insensitive). + const m = /^Content-Length:\s*(\d+)\s*$/im.exec(header); + if (!m) { + stray.push(buffer.subarray(0, hdrEnd + HEADER_END.length)); + buffer = buffer.subarray(hdrEnd + HEADER_END.length); + continue; + } + // If there's text between buffer start and the header line, it's stray + // unless the entire header parsed cleanly with no preamble. + const headerStart = header.search(/Content-Length:/i); + if (headerStart > 0) { + stray.push(buffer.subarray(0, headerStart)); + buffer = buffer.subarray(headerStart); + continue; + } + expected = parseInt(m[1], 10); + buffer = buffer.subarray(hdrEnd + HEADER_END.length); + state = 1; + } + if (state === 1) { + if (buffer.length < expected) return; + const bodyBuf = buffer.subarray(0, expected); + buffer = buffer.subarray(expected); + state = 0; + try { + pushMessage(JSON.parse(bodyBuf.toString('utf8'))); + } catch (err) { + // Body that doesn't parse as JSON is a fatal protocol error. + stray.push(bodyBuf); + } + } + } + } + + proc.stdout.on('data', (chunk: Buffer) => { + stdoutChunks.push(chunk); + buffer = Buffer.concat([buffer, chunk]); + tryParse(); + }); + proc.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + }); + + return { + proc, + stdoutChunks, + stderrChunks, + nextMessage: () => + new Promise((resolve, reject) => { + if (messageQueue.length > 0) { + resolve(messageQueue.shift()); + return; + } + const timer = setTimeout(() => { + reject( + new Error( + `Timed out waiting for JSON-RPC message. stderr so far:\n${Buffer.concat(stderrChunks).toString('utf8')}`, + ), + ); + }, TOTAL_BUDGET_MS); + waiters.push((msg) => { + clearTimeout(timer); + resolve(msg); + }); + }), + strayStdoutBytes: () => { + // Include any leftover unparsed buffer. + const tail = state === 0 ? buffer : Buffer.alloc(0); + return Buffer.concat([...stray, tail]); + }, + send: (message: unknown) => { + const body = JSON.stringify(message); + const frame = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`; + proc.stdin.write(frame); + }, + close: async () => { + proc.stdin.end(); + // Give the server a moment to clean up; force-kill if it hangs. + await new Promise((resolve) => { + const killer = setTimeout(() => { + proc.kill('SIGKILL'); + resolve(); + }, 2000); + proc.on('close', () => { + clearTimeout(killer); + resolve(); + }); + }); + }, + }; +} + +describe('MCP server end-to-end startup', () => { + it('preserves JSON-RPC stdout discipline through initialize + tools/list', async () => { + if (!fs.existsSync(DIST_CLI)) { + throw new Error( + `dist/cli/index.js missing — run \`npm run build\` first (or use \`npm run test:integration\` which builds via pretest:integration).`, + ); + } + + const server = spawnMcpServer(); + try { + // initialize handshake + const startedAt = Date.now(); + server.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'gitnexus-startup-test', version: '0.0.0' }, + }, + }); + + const initResponse = (await server.nextMessage()) as { + jsonrpc: string; + id: number; + result?: { protocolVersion: string; serverInfo: { name: string } }; + error?: unknown; + }; + const firstFrameAt = Date.now(); + + expect(initResponse.jsonrpc).toBe('2.0'); + expect(initResponse.id).toBe(1); + expect(initResponse.error).toBeUndefined(); + expect(initResponse.result).toBeDefined(); + expect(initResponse.result!.serverInfo.name).toMatch(/gitnexus/i); + expect(firstFrameAt - startedAt).toBeLessThan(FIRST_FRAME_BUDGET_MS); + + // initialized notification (no response expected) + server.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + // tools/list + server.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' }); + const toolsResponse = (await server.nextMessage()) as { + jsonrpc: string; + id: number; + result?: { tools: Array<{ name: string }> }; + }; + + expect(toolsResponse.id).toBe(2); + expect(toolsResponse.result).toBeDefined(); + const toolNames = (toolsResponse.result!.tools ?? []).map((t) => t.name); + // The published GitNexus tool set. Adjust if the surface changes. + const expectedTools = [ + 'list_repos', + 'query', + 'context', + 'impact', + 'detect_changes', + 'rename', + ]; + for (const t of expectedTools) { + expect(toolNames).toContain(t); + } + + // The headline assertion: every byte the server emitted on stdout + // must reassemble into a valid JSON-RPC frame. Any leftover is a + // protocol-corruption regression. + const stray = server.strayStdoutBytes(); + if (stray.length > 0) { + const stderr = Buffer.concat(server.stderrChunks).toString('utf8'); + throw new Error( + `Stdout contained ${stray.length} bytes outside JSON-RPC framing — protocol corruption regression.\nStray bytes (utf8): ${JSON.stringify(stray.toString('utf8'))}\nStderr from server:\n${stderr}`, + ); + } + } finally { + await server.close(); + } + }, 60_000); +}); diff --git a/gitnexus/test/integration/setup-skills.test.ts b/gitnexus/test/integration/setup-skills.test.ts index 1ceb721fc..defe6771c 100644 --- a/gitnexus/test/integration/setup-skills.test.ts +++ b/gitnexus/test/integration/setup-skills.test.ts @@ -52,6 +52,21 @@ describe('setupCommand skills integration', () => { await fs.rm(tempHome, { recursive: true, force: true }); }); + it('reports the OpenCode skills install path with the plural skills directory', async () => { + await fs.mkdir(path.join(tempHome, '.config', 'opencode'), { recursive: true }); + await setupCommand(); + + const installedSkill = await fs.readFile( + path.join(tempHome, '.config', 'opencode', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + + expect(installedSkill).toContain('GitNexus CLI Commands'); + await expect( + fs.access(path.join(tempHome, '.config', 'opencode', 'skill', 'gitnexus-cli', 'SKILL.md')), + ).rejects.toThrow(); + }); + it('installs packaged, flat-file, and directory skills into cursor skills directory', async () => { await setupCommand(); @@ -96,7 +111,7 @@ describe('setupCommand skills integration', () => { const codexConfig = await fs.readFile(path.join(tempHome, '.codex', 'config.toml'), 'utf-8'); expect(codexConfig).toContain('[mcp_servers.gitnexus]'); - expect(codexConfig).toContain('gitnexus@latest'); + expect(codexConfig).toMatch(/gitnexus@\d+\.\d+\.\d+/); const codexSkill = await fs.readFile( path.join(tempHome, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts index eb0baebdf..845c1cfba 100644 --- a/gitnexus/test/integration/worker-pool.test.ts +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -6,13 +6,14 @@ * This is critical for cross-platform CI where vitest runs from src/ * but workers need compiled .js files. */ -import { describe, it, expect, afterEach, vi } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import { createWorkerPool, WorkerPool } from '../../src/core/ingestion/workers/worker-pool.js'; import { pathToFileURL } from 'node:url'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; +import { _captureLogger } from '../../src/core/logger.js'; const DIST_WORKER = path.resolve( __dirname, '..', @@ -211,7 +212,7 @@ describe('worker pool integration', () => { `, ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); const workerUrl = pathToFileURL(workerPath) as URL; pool = createWorkerPool(workerUrl, 1); @@ -221,9 +222,9 @@ describe('worker pool integration', () => { ]); expect(results).toHaveLength(1); expect(results[0].fileCount).toBe(1); - expect(warnSpy).toHaveBeenCalledWith('warning before result'); + expect(cap.records().some((r) => r.msg === 'warning before result')).toBe(true); } finally { - warnSpy.mockRestore(); + cap.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); } }); @@ -298,9 +299,9 @@ describe('worker pool integration', () => { `, ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, { - subBatchIdleTimeoutMs: 150, + subBatchIdleTimeoutMs: 500, maxTimeoutRetries: 1, timeoutBackoffFactor: 4, }); @@ -308,9 +309,57 @@ describe('worker pool integration', () => { try { const results = await pool.dispatch([{ path: 'retry.ts', content: '' }]); expect(results).toEqual([{ fileCount: 1, recovered: true }]); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Retrying with 0.6s timeout')); + // 500ms idle timeout × 4 backoff factor = 2000ms = "2s" in the retry log. + expect( + cap.records().some((r) => String(r.msg ?? '').includes('Retrying with 2s timeout')), + ).toBe(true); } finally { - warnSpy.mockRestore(); + cap.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects dispatch when replacement worker crashes during startup', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-replace-fail-')); + const markerPath = path.join(tempDir, 'first-attempt.txt'); + const workerPath = path.join(tempDir, 'worker.js'); + fs.writeFileSync( + workerPath, + ` + const fs = require('node:fs'); + const { parentPort } = require('node:worker_threads'); + const markerPath = ${JSON.stringify(markerPath)}; + if (fs.existsSync(markerPath)) { + throw new Error('simulated startup crash'); + } + parentPort.on('message', (msg) => { + if (msg && msg.type === 'sub-batch') { + fs.writeFileSync(markerPath, 'stalled'); + return; + } + }); + `, + ); + + // Capture pino output AND assert on it: the worker pool should emit a + // warn-level record naming the crash before rejecting, so an operator + // can tell a startup-crash from a stalled-worker rejection. Asserting + // here keeps coverage parity with the prior console.warn spy version. + const cap = _captureLogger(); + pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, { + subBatchIdleTimeoutMs: 150, + maxTimeoutRetries: 1, + timeoutBackoffFactor: 4, + }); + + try { + await expect(pool.dispatch([{ path: 'crash.ts', content: '' }])).rejects.toThrow( + /simulated startup crash|exited with code/, + ); + const warnRecords = cap.records().filter((r) => Number(r.level) >= 40 /* warn or above */); + expect(warnRecords.length).toBeGreaterThan(0); + } finally { + cap.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); } }); @@ -344,7 +393,7 @@ describe('worker pool integration', () => { `, ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, { subBatchSize: 2, subBatchIdleTimeoutMs: 150, @@ -372,9 +421,11 @@ describe('worker pool integration', () => { ]); expect(progressCalls).toEqual([...progressCalls].sort((a, b) => a - b)); expect(progressCalls.at(-1)).toBe(4); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Splitting into 1/1 item jobs')); + expect( + cap.records().some((r) => String(r.msg ?? '').includes('Splitting into 1/1 item jobs')), + ).toBe(true); } finally { - warnSpy.mockRestore(); + cap.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); } }); @@ -440,7 +491,7 @@ describe('worker pool integration', () => { `, ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); pool = createWorkerPool(pathToFileURL(workerPath) as URL, 2, { subBatchSize: 2, subBatchIdleTimeoutMs: 150, @@ -466,9 +517,11 @@ describe('worker pool integration', () => { 'tail-a.ts', 'tail-b.ts', ]); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Splitting into 1/1 item jobs')); + expect( + cap.records().some((r) => String(r.msg ?? '').includes('Splitting into 1/1 item jobs')), + ).toBe(true); } finally { - warnSpy.mockRestore(); + cap.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); } }); @@ -504,7 +557,7 @@ describe('worker pool integration', () => { `, ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); // 2 workers but subBatchSize=4 means all 4 items form 1 job; second worker stays idle. pool = createWorkerPool(pathToFileURL(workerPath) as URL, 2, { subBatchSize: 4, @@ -523,9 +576,9 @@ describe('worker pool integration', () => { const allPaths = results.flatMap((r: any) => r.paths); expect(allPaths.sort()).toEqual(['a.ts', 'b.ts', 'c.ts', 'd.ts']); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Splitting into')); + expect(cap.records().some((r) => String(r.msg ?? '').includes('Splitting into'))).toBe(true); } finally { - warnSpy.mockRestore(); + cap.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); } }, 15_000); diff --git a/gitnexus/test/unit/analyze-embeddings-limit.test.ts b/gitnexus/test/unit/analyze-embeddings-limit.test.ts new file mode 100644 index 000000000..6fbc5af56 --- /dev/null +++ b/gitnexus/test/unit/analyze-embeddings-limit.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const runFullAnalysisMock = vi.fn(); + +vi.mock('../../src/core/run-analyze.js', () => ({ + runFullAnalysis: runFullAnalysisMock, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + closeLbug: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), + getGlobalRegistryPath: vi.fn(() => 'registry.json'), + RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, + AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, + assertAnalysisFinalized: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(() => '/repo'), + hasGitDir: vi.fn(() => true), +})); + +vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ + getMaxFileSizeBannerMessage: vi.fn(() => null), +})); + +describe('analyzeCommand --embeddings [limit] parsing', () => { + beforeEach(() => { + vi.resetModules(); + runFullAnalysisMock.mockReset(); + runFullAnalysisMock.mockResolvedValue({ + repoName: 'repo', + repoPath: '/repo', + stats: {}, + alreadyUpToDate: true, + }); + process.exitCode = undefined; + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); + }); + + it.each(['abc', '-1', '1.5', 'NaN', 'Infinity'])( + 'rejects invalid --embeddings value %s before analysis starts', + async (embeddings) => { + // The validator routes through cli-message (`cliError`), which + // writes plain text directly to process.stderr. Spy on the raw + // stderr handle rather than `console.error`, since the migration + // bypasses console entirely. + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings }); + + expect(process.exitCode).toBe(1); + expect(runFullAnalysisMock).not.toHaveBeenCalled(); + const allWrites = stderrSpy.mock.calls + .map(([chunk]) => (typeof chunk === 'string' ? chunk : chunk.toString())) + .join(''); + expect(allWrites).toContain('--embeddings expects a non-negative integer'); + expect(allWrites).toContain(`got "${embeddings}"`); + stderrSpy.mockRestore(); + }, + ); + + it('bare --embeddings forwards undefined limit (default cap honored downstream)', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: true }); + + expect(runFullAnalysisMock).toHaveBeenCalledTimes(1); + const opts = runFullAnalysisMock.mock.calls[0][1]; + expect(opts.embeddings).toBe(true); + expect(opts.embeddingsNodeLimit).toBeUndefined(); + }); + + it('--embeddings 0 forwards 0 (cap disabled downstream)', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: '0' }); + + const opts = runFullAnalysisMock.mock.calls[0][1]; + expect(opts.embeddings).toBe(true); + expect(opts.embeddingsNodeLimit).toBe(0); + }); + + it('--embeddings forwards a positive custom cap', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: '100000' }); + + const opts = runFullAnalysisMock.mock.calls[0][1]; + expect(opts.embeddings).toBe(true); + expect(opts.embeddingsNodeLimit).toBe(100_000); + }); + + it('omitted --embeddings keeps embeddings off (boolean false, no limit)', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, {}); + + const opts = runFullAnalysisMock.mock.calls[0][1]; + expect(opts.embeddings).toBe(false); + expect(opts.embeddingsNodeLimit).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/analyze-worker-timeout.test.ts b/gitnexus/test/unit/analyze-worker-timeout.test.ts index dcf5a9dce..3ed3a3d5e 100644 --- a/gitnexus/test/unit/analyze-worker-timeout.test.ts +++ b/gitnexus/test/unit/analyze-worker-timeout.test.ts @@ -39,15 +39,20 @@ describe('analyzeCommand worker timeout validation', () => { it.each(['0', 'abc', '-5', 'Infinity'])( 'rejects invalid --worker-timeout value %s before analysis starts', async (workerTimeout) => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + // Import _captureLogger from the SAME module instance analyze.js will + // see — vi.resetModules() in beforeEach invalidates the singleton. + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { workerTimeout }); expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith(' --worker-timeout must be at least 1 second.\n'); + expect( + cap.records().some((r) => r.msg === ' --worker-timeout must be at least 1 second.\n'), + ).toBe(true); expect(runFullAnalysisMock).not.toHaveBeenCalled(); - errorSpy.mockRestore(); + cap.restore(); }, ); diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index aa422327b..f8d94d890 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -71,6 +71,7 @@ vi.mock('../../src/mcp/core/embedder.js', () => ({ import { LocalBackend } from '../../src/mcp/local/local-backend.js'; import { listRegisteredRepos, cleanupOldKuzuFiles } from '../../src/storage/repo-manager.js'; +import { _captureLogger } from '../../src/core/logger.js'; import { initLbug, executeQuery, @@ -194,7 +195,7 @@ describe('LocalBackend.callTool', () => { }); it('skips vector index query when VECTOR is unsupported by the platform', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const cap = _captureLogger(); platformMocks.isVectorExtensionSupportedByPlatform.mockReturnValue(false); (executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => { if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }]; @@ -217,13 +218,17 @@ describe('LocalBackend.callTool', () => { cypher.includes('e.embedding AS embedding'), ), ).toBe(true); - expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining( - 'GitNexus [query:vector]: VECTOR extension not supported on this platform', - ), - ); + expect( + cap + .records() + .some((r) => + String(r.msg ?? '').includes( + 'GitNexus [query:vector]: VECTOR extension not supported on this platform', + ), + ), + ).toBe(true); } finally { - consoleError.mockRestore(); + cap.restore(); } }); @@ -835,7 +840,7 @@ describe('LocalBackend.resolveRepo', () => { hint: '⚠️ stale sibling clone', }); - const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const cap = _captureLogger(); try { await backend.init(); @@ -846,13 +851,15 @@ describe('LocalBackend.resolveRepo', () => { await backend.resolveRepo(); await backend.resolveRepo(); - const drift = errSpy.mock.calls.filter((c) => String(c[0]).includes('stale sibling clone')); + const drift = cap + .records() + .filter((r) => String(r.msg ?? '').includes('stale sibling clone')); expect(drift).toHaveLength(1); // checkCwdMatch should also only run once — the cache check // happens BEFORE the shellout-heavy match call. expect(checkCwdMatch).toHaveBeenCalledTimes(1); } finally { - errSpy.mockRestore(); + cap.restore(); (checkCwdMatch as any).mockResolvedValue({ match: 'none' }); } }); diff --git a/gitnexus/test/unit/cli-message.test.ts b/gitnexus/test/unit/cli-message.test.ts new file mode 100644 index 000000000..321485c37 --- /dev/null +++ b/gitnexus/test/unit/cli-message.test.ts @@ -0,0 +1,99 @@ +/** + * Unit tests for `gitnexus/src/cli/cli-message.ts`. + * + * cli-message is the helper for user-facing CLI banners and error guidance. + * The contract: each call writes plain text to stderr AND emits a + * structured pino record through the singleton logger. + * + * Tests verify both halves of the tee, plus shape contracts (newline + * handling, structured fields, tee survival across messages with + * embedded newlines). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { cliInfo, cliWarn, cliError } from '../../src/cli/cli-message.js'; +import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js'; + +describe('cli-message — stderr + logger tee', () => { + let cap: LoggerCapture; + let stderrSpy: ReturnType; + + beforeEach(() => { + cap = _captureLogger(); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + stderrSpy.mockRestore(); + cap.restore(); + }); + + it('cliInfo writes plain text to stderr and emits a structured info record', () => { + cliInfo('hello'); + // Plain stderr write + const stderrCalls = stderrSpy.mock.calls.map(([chunk]) => + typeof chunk === 'string' ? chunk : chunk.toString(), + ); + expect(stderrCalls).toContain('hello\n'); + // Structured logger record + const records = cap.records(); + expect(records.some((r) => r.msg === 'hello' && r.level === 30)).toBe(true); + }); + + it('cliWarn writes to stderr and emits at warn level (40)', () => { + cliWarn('caution'); + const stderrCalls = stderrSpy.mock.calls.map(([chunk]) => + typeof chunk === 'string' ? chunk : chunk.toString(), + ); + expect(stderrCalls).toContain('caution\n'); + const records = cap.records(); + expect(records.some((r) => r.msg === 'caution' && r.level === 40)).toBe(true); + }); + + it('cliError writes to stderr and emits at error level (50) with structured fields', () => { + cliError('boom', { code: 'EADDRINUSE', port: 4747 }); + const stderrCalls = stderrSpy.mock.calls.map(([chunk]) => + typeof chunk === 'string' ? chunk : chunk.toString(), + ); + expect(stderrCalls).toContain('boom\n'); + const records = cap.records(); + const errorRecord = records.find((r) => r.msg === 'boom' && r.level === 50); + expect(errorRecord).toBeDefined(); + expect(errorRecord?.code).toBe('EADDRINUSE'); + expect(errorRecord?.port).toBe(4747); + }); + + it('does not double-newline an already-newlined message', () => { + cliInfo('already-terminated\n'); + const stderrCalls = stderrSpy.mock.calls.map(([chunk]) => + typeof chunk === 'string' ? chunk : chunk.toString(), + ); + // Exactly one trailing \n, not two. + expect(stderrCalls).toContain('already-terminated\n'); + expect(stderrCalls.includes('already-terminated\n\n')).toBe(false); + }); + + it('preserves embedded newlines in multi-line messages (does not split into multiple records)', () => { + cliError('line one\nline two\nline three'); + const stderrCalls = stderrSpy.mock.calls.map(([chunk]) => + typeof chunk === 'string' ? chunk : chunk.toString(), + ); + // The whole multi-line block goes to stderr in one write, with a + // trailing newline appended. + expect(stderrCalls).toContain('line one\nline two\nline three\n'); + // The structured record carries the full message as a single field. + const records = cap.records(); + expect(records.some((r) => r.msg === 'line one\nline two\nline three' && r.level === 50)).toBe( + true, + ); + }); + + it('handles an empty message — stderr gets a bare newline, logger gets msg:""', () => { + cliInfo(''); + const stderrCalls = stderrSpy.mock.calls.map(([chunk]) => + typeof chunk === 'string' ? chunk : chunk.toString(), + ); + expect(stderrCalls).toContain('\n'); + const records = cap.records(); + expect(records.some((r) => r.msg === '' && r.level === 30)).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/git-utils.test.ts b/gitnexus/test/unit/git-utils.test.ts index e8fcdf6b9..dba2b8886 100644 --- a/gitnexus/test/unit/git-utils.test.ts +++ b/gitnexus/test/unit/git-utils.test.ts @@ -4,7 +4,7 @@ * Tests isGitRepo, getCurrentCommit, getGitRoot, and the newly added * hasGitDir helper introduced for issue #384 (indexing non-git folders). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import path from 'path'; import os from 'os'; import fs from 'fs'; @@ -97,6 +97,26 @@ describe('getCurrentCommit', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + // Regression: #1172 — without explicit stdio on execSync, Node forwards + // the child's stderr to the parent process, printing "fatal: not a git + // repository" to the user's terminal even though the error is caught. + it('does not leak git stderr to process.stderr (#1172)', async () => { + const { getCurrentCommit } = await import('../../src/storage/git.js'); + // git-init a dir without commits so `git rev-parse HEAD` fails with a + // "fatal:" message — the exact class of error that leaked before the fix. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' }); + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + expect(getCurrentCommit(tmpDir)).toBe(''); + const stderrOutput = spy.mock.calls.map((c) => String(c[0])).join(''); + expect(stderrOutput).not.toContain('fatal'); + } finally { + spy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); // ─── getGitRoot ─────────────────────────────────────────────────────────── @@ -111,6 +131,21 @@ describe('getGitRoot', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + // Regression: #1172 -- mirrors the getCurrentCommit stderr test above. + it('does not leak git stderr to process.stderr (#1172)', async () => { + const { getGitRoot } = await import('../../src/storage/git.js'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + getGitRoot(tmpDir); + const stderrOutput = spy.mock.calls.map((c) => String(c[0])).join(''); + expect(stderrOutput).not.toContain('fatal'); + } finally { + spy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); // ─── getRemoteUrl ───────────────────────────────────────────────────────── diff --git a/gitnexus/test/unit/group/config-parser.test.ts b/gitnexus/test/unit/group/config-parser.test.ts index 7bfc2cf8f..e1d3b540f 100644 --- a/gitnexus/test/unit/group/config-parser.test.ts +++ b/gitnexus/test/unit/group/config-parser.test.ts @@ -64,6 +64,36 @@ repos: expect(config.matching.exclude_links_param_only_paths).toBe(false); }); + it('defaults thrift detection to true', () => { + const minimal = ` +version: 1 +name: test +repos: + app: my-app +`; + const config = parseGroupConfig(minimal); + expect(config.detect.thrift).toBe(true); + }); + + it('parses thrift manifest links', () => { + const yaml = ` +version: 1 +name: test +repos: + gateway: gateway-repo + orders: orders-repo +links: + - from: gateway + to: orders + type: thrift + contract: billing.v1.OrderService/PlaceOrder + role: consumer +`; + const config = parseGroupConfig(yaml); + expect(config.links[0].type).toBe('thrift'); + expect(config.links[0].contract).toBe('billing.v1.OrderService/PlaceOrder'); + }); + it('throws on missing required fields', () => { expect(() => parseGroupConfig('version: 1')).toThrow(/name.*required/i); expect(() => parseGroupConfig('name: test')).toThrow(/version.*required/i); diff --git a/gitnexus/test/unit/group/grpc-extractor.test.ts b/gitnexus/test/unit/group/grpc-extractor.test.ts index a586b5c0d..b128c6ddf 100644 --- a/gitnexus/test/unit/group/grpc-extractor.test.ts +++ b/gitnexus/test/unit/group/grpc-extractor.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import fsp from 'node:fs/promises'; import * as path from 'node:path'; @@ -11,6 +11,7 @@ import { } from '../../../src/core/group/extractors/grpc-extractor.js'; import type { ProtoServiceInfo } from '../../../src/core/group/extractors/grpc-extractor.js'; import type { RepoHandle } from '../../../src/core/group/types.js'; +import { _captureLogger } from '../../../src/core/logger.js'; describe('GrpcExtractor', () => { let tmpDir: string; @@ -797,18 +798,18 @@ describe('resolveProtoConflict', () => { }); it('test_all_zero_tie_returns_null', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cap = _captureLogger(); const candidates = [ makeInfo('pkgA', 'totally/unrelated/a/svc.proto'), makeInfo('pkgB', 'completely/different/b/svc.proto'), ]; const result = resolveProtoConflict('Svc', 'src/main.go', candidates); expect(result).toBeNull(); - warnSpy.mockRestore(); + cap.restore(); }); it('test_positive_score_tie_returns_null', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cap = _captureLogger(); // Both candidates share `src/proto` with the source dir — equal shared runs. const candidates = [ makeInfo('pkgA', 'src/proto/a/svc.proto'), @@ -816,11 +817,11 @@ describe('resolveProtoConflict', () => { ]; const result = resolveProtoConflict('Svc', 'src/proto/main.go', candidates); expect(result).toBeNull(); - warnSpy.mockRestore(); + cap.restore(); }); it('test_three_way_zero_tie_returns_null', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cap = _captureLogger(); const candidates = [ makeInfo('pkgA', 'aaa/svc.proto'), makeInfo('pkgB', 'bbb/svc.proto'), @@ -828,7 +829,7 @@ describe('resolveProtoConflict', () => { ]; const result = resolveProtoConflict('Svc', 'src/main.go', candidates); expect(result).toBeNull(); - warnSpy.mockRestore(); + cap.restore(); }); it('test_unique_winner_among_ties', () => { @@ -843,19 +844,19 @@ describe('resolveProtoConflict', () => { }); it('test_ambiguous_emits_single_warn_with_service_and_paths', () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cap = _captureLogger(); const candidates = [ makeInfo('pkgA', 'totally/unrelated/a/svc.proto'), makeInfo('pkgB', 'completely/different/b/svc.proto'), ]; resolveProtoConflict('MyService', 'src/main.go', candidates); - expect(warnSpy).toHaveBeenCalledTimes(1); - const msg = String(warnSpy.mock.calls[0][0]); + expect(cap.records().length).toBe(1); + const msg = String(String(cap.records()[0]?.msg ?? '')); expect(msg).toContain('MyService'); expect(msg).toContain('src/main.go'); expect(msg).toContain('totally/unrelated/a/svc.proto'); expect(msg).toContain('completely/different/b/svc.proto'); - warnSpy.mockRestore(); + cap.restore(); }); }); @@ -879,7 +880,7 @@ describe('GrpcExtractor.extract ambiguous proto resolution', () => { }); it('test_ambiguous_short_name_across_unrelated_protos_yields_no_source_contract', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cap = _captureLogger(); // Two unrelated proto files defining the same short name `UserService` in // unrelated directories, neither sharing path segments with the Go source. await fsp.mkdir(path.join(tmpDir, 'billing-team', 'proto'), { recursive: true }); @@ -906,8 +907,8 @@ describe('GrpcExtractor.extract ambiguous proto resolution', () => { (c) => c.meta.source === 'go_client' && c.meta.service === 'UserService', ); expect(sourceContracts).toHaveLength(0); - expect(warnSpy).toHaveBeenCalled(); - warnSpy.mockRestore(); + expect(cap.records().length).toBeGreaterThan(0); + cap.restore(); }); }); diff --git a/gitnexus/test/unit/group/insecure-tempfile.test.ts b/gitnexus/test/unit/group/insecure-tempfile.test.ts new file mode 100644 index 000000000..17798a199 --- /dev/null +++ b/gitnexus/test/unit/group/insecure-tempfile.test.ts @@ -0,0 +1,192 @@ +/** + * Security tests for insecure tempfile remediation (#1318 U6). + * + * CodeQL js/insecure-temporary-file flags predictable temp filenames + * (e.g. Date.now() suffix) because an attacker with write access to + * the same directory can win a symlink race. The fix replaces all + * predictable suffixes with crypto.randomBytes(8). + * + * Two layers: + * 1. Structural — source-grep confirms randomBytes, not Date.now(). + * 2. Behavioural — writeContractRegistry produces no leftover tmp files + * and the final file is correctly written. + */ +import { beforeAll, describe, expect, it, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { writeContractRegistry, readContractRegistry } from '../../../src/core/group/storage.js'; +import { writeBridgeMeta, readBridgeMeta } from '../../../src/core/group/bridge-db.js'; +import type { ContractRegistry, BridgeMeta } from '../../../src/core/group/types.js'; + +// --------------------------------------------------------------------------- +// Structural: source files use randomBytes, not Date.now(), for temp paths +// --------------------------------------------------------------------------- + +describe('insecure tempfile — structural guards (#1318 U6)', () => { + let bridgeSource: string; + let storageSource: string; + + beforeAll(async () => { + bridgeSource = await fsp.readFile( + path.join(__dirname, '..', '..', '..', 'src', 'core', 'group', 'bridge-db.ts'), + 'utf-8', + ); + storageSource = await fsp.readFile( + path.join(__dirname, '..', '..', '..', 'src', 'core', 'group', 'storage.ts'), + 'utf-8', + ); + }); + + it('bridge-db.ts imports randomBytes from node:crypto', () => { + expect(bridgeSource).toMatch(/import\s*\{[^}]*randomBytes[^}]*\}\s*from\s*'node:crypto'/); + }); + + it('bridge-db.ts uses randomBytes for bridge.lbug temp path', () => { + expect(bridgeSource).toMatch(/bridge\.lbug\.tmp\.\$\{randomBytes/); + }); + + it('bridge-db.ts uses randomBytes for meta.json temp path', () => { + expect(bridgeSource).toMatch(/\.tmp\.\$\{randomBytes\(8\)\.toString\('hex'\)\}/); + }); + + it('bridge-db.ts does not use Date.now() in any temp path', () => { + // Match Date.now() specifically in tmp-path contexts — not in unrelated code. + const tmpDateNow = bridgeSource.match(/\.tmp\.\$\{Date\.now\(\)\}/g) ?? []; + expect(tmpDateNow.length).toBe(0); + }); + + it('bridge-db.ts uses readdir-based cleanup for stale bridge tmp files', () => { + expect(bridgeSource).toMatch(/cleanStaleBridgeTmpFiles/); + expect(bridgeSource).toMatch(/readdir\(groupDir\)/); + expect(bridgeSource).toMatch(/startsWith\('bridge\.lbug\.tmp\.'\)/); + }); + + it('bridge-db.ts calls cleanStaleBridgeTmpFiles before openBridgeDb in writeBridge', () => { + // Ensure cleanup happens before the DB is opened with the new random path. + const cleanIdx = bridgeSource.indexOf('cleanStaleBridgeTmpFiles(groupDir)'); + const openIdx = bridgeSource.indexOf('openBridgeDb(tmpPath)'); + expect(cleanIdx).toBeGreaterThan(-1); + expect(openIdx).toBeGreaterThan(-1); + expect(cleanIdx).toBeLessThan(openIdx); + }); + + it('storage.ts imports randomBytes from node:crypto', () => { + expect(storageSource).toMatch(/import\s*\{[^}]*randomBytes[^}]*\}\s*from\s*'node:crypto'/); + }); + + it('storage.ts uses randomBytes for contracts.json temp path', () => { + expect(storageSource).toMatch(/\.tmp\.\$\{randomBytes\(8\)\.toString\('hex'\)\}/); + }); + + it('storage.ts does not use Date.now() in any temp path', () => { + const tmpDateNow = storageSource.match(/\.tmp\.\$\{Date\.now\(\)\}/g) ?? []; + expect(tmpDateNow.length).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Behavioural: writeContractRegistry atomic write leaves no tmp files +// --------------------------------------------------------------------------- + +describe('insecure tempfile — behavioural (#1318 U6)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-u6-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const sampleRegistry: ContractRegistry = { + version: 1, + generatedAt: '2026-05-06T00:00:00Z', + repoSnapshots: {}, + missingRepos: [], + contracts: [], + crossLinks: [], + }; + + it('writeContractRegistry leaves no .tmp files after completion', async () => { + await writeContractRegistry(tmpDir, sampleRegistry); + + const files = await fsp.readdir(tmpDir); + const tmpFiles = files.filter((f) => f.includes('.tmp.')); + expect(tmpFiles).toEqual([]); + }); + + it('writeContractRegistry writes correct data to final path', async () => { + await writeContractRegistry(tmpDir, sampleRegistry); + + const loaded = await readContractRegistry(tmpDir); + expect(loaded).not.toBeNull(); + expect(loaded!.version).toBe(1); + expect(loaded!.generatedAt).toBe('2026-05-06T00:00:00Z'); + }); + + it('concurrent writes do not collide (randomBytes prevents same-ms race)', async () => { + // Fire two writes simultaneously — with Date.now() these could collide + // if they land in the same millisecond. With randomBytes they can't. + await Promise.all([ + writeContractRegistry(tmpDir, { ...sampleRegistry, generatedAt: 'A' }), + writeContractRegistry(tmpDir, { ...sampleRegistry, generatedAt: 'B' }), + ]); + + const loaded = await readContractRegistry(tmpDir); + expect(loaded).not.toBeNull(); + // One of the two writes wins the rename — we just verify no crash. + expect(['A', 'B']).toContain(loaded!.generatedAt); + }); +}); + +// --------------------------------------------------------------------------- +// Behavioural: writeBridgeMeta atomic write leaves no tmp files +// --------------------------------------------------------------------------- + +describe('insecure tempfile — writeBridgeMeta behavioural (#1318 U6)', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-u6-meta-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + const sampleMeta: BridgeMeta = { + version: 1, + generatedAt: '2026-05-06T00:00:00Z', + missingRepos: ['repo-x'], + }; + + it('writeBridgeMeta leaves no .tmp files after completion', async () => { + await writeBridgeMeta(tmpDir, sampleMeta); + + const files = await fsp.readdir(tmpDir); + const tmpFiles = files.filter((f) => f.includes('.tmp.')); + expect(tmpFiles).toEqual([]); + }); + + it('writeBridgeMeta writes correct data to meta.json', async () => { + await writeBridgeMeta(tmpDir, sampleMeta); + + const loaded = await readBridgeMeta(tmpDir); + expect(loaded.version).toBe(1); + expect(loaded.generatedAt).toBe('2026-05-06T00:00:00Z'); + expect(loaded.missingRepos).toEqual(['repo-x']); + }); + + it('concurrent writeBridgeMeta calls do not collide', async () => { + await Promise.all([ + writeBridgeMeta(tmpDir, { ...sampleMeta, generatedAt: 'A' }), + writeBridgeMeta(tmpDir, { ...sampleMeta, generatedAt: 'B' }), + ]); + + const loaded = await readBridgeMeta(tmpDir); + expect(['A', 'B']).toContain(loaded.generatedAt); + }); +}); diff --git a/gitnexus/test/unit/group/manifest-extractor.test.ts b/gitnexus/test/unit/group/manifest-extractor.test.ts index bb4e203fa..c732a16a3 100644 --- a/gitnexus/test/unit/group/manifest-extractor.test.ts +++ b/gitnexus/test/unit/group/manifest-extractor.test.ts @@ -169,6 +169,90 @@ describe('ManifestExtractor', () => { expect(provider?.symbolUid).toBe('uid-correct-login'); }); + it('resolves grpc package-qualified service-only manifest by full service name', async () => { + const links: GroupManifestLink[] = [ + { + from: 'platform/orders', + to: 'platform/auth', + type: 'grpc', + contract: 'auth.AuthService', + role: 'consumer', + }, + ]; + + let seenServiceName: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'platform/auth', + async (_cypher, params) => { + seenServiceName = params?.serviceName as string; + if (params?.serviceName === 'auth.AuthService') { + return [ + { + uid: 'uid-auth-service', + name: 'auth.AuthService', + filePath: 'src/auth.proto', + }, + ]; + } + return []; + }, + ], + ['platform/orders', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + expect(seenServiceName).toBe('auth.AuthService'); + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.symbolUid).toBe('uid-auth-service'); + }); + + it('resolves thrift package-qualified service-only manifest by simple service name', async () => { + const links: GroupManifestLink[] = [ + { + from: 'gateway', + to: 'orders', + type: 'thrift', + contract: 'billing.v1.OrderService', + role: 'consumer', + }, + ]; + + let seenServiceName: string | undefined; + const dbExecutors = new Map< + string, + (cypher: string, params?: Record) => Promise[]> + >([ + [ + 'orders', + async (_cypher, params) => { + seenServiceName = params?.serviceName as string; + if (params?.serviceName === 'OrderService') { + return [ + { + uid: 'uid-order-service', + name: 'OrderService', + filePath: 'idl/order.thrift', + }, + ]; + } + return []; + }, + ], + ['gateway', async () => []], + ]); + + const result = await extractor.extractFromManifest(links, dbExecutors); + + expect(seenServiceName).toBe('OrderService'); + const provider = result.contracts.find((c) => c.role === 'provider'); + expect(provider?.symbolUid).toBe('uid-order-service'); + }); + it('resolves lib manifest links by exact name only', async () => { const links: GroupManifestLink[] = [ { @@ -578,6 +662,33 @@ describe('ManifestExtractor', () => { expect(lowerContractId).toBe(upperContractId); }); + it('builds thrift manifest contracts with synthetic uids when unresolved', async () => { + const extractor = new ManifestExtractor(); + const result = await extractor.extractFromManifest([ + { + from: 'gateway', + to: 'orders', + type: 'thrift', + contract: 'billing.v1.OrderService/PlaceOrder', + role: 'consumer', + }, + ]); + + expect(result.contracts).toHaveLength(2); + expect(result.contracts.map((c) => c.contractId)).toEqual([ + 'thrift::billing.v1.OrderService/PlaceOrder', + 'thrift::billing.v1.OrderService/PlaceOrder', + ]); + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].type).toBe('thrift'); + expect(result.crossLinks[0].from.symbolUid).toBe( + 'manifest::gateway::thrift::billing.v1.OrderService/PlaceOrder', + ); + expect(result.crossLinks[0].to.symbolUid).toBe( + 'manifest::orders::thrift::billing.v1.OrderService/PlaceOrder', + ); + }); + it('resolves custom manifest links by exact symbol name', async () => { const links: GroupManifestLink[] = [ { diff --git a/gitnexus/test/unit/group/matching.test.ts b/gitnexus/test/unit/group/matching.test.ts index 5c29bb4a6..4baeee454 100644 --- a/gitnexus/test/unit/group/matching.test.ts +++ b/gitnexus/test/unit/group/matching.test.ts @@ -22,6 +22,16 @@ describe('normalizeContractId', () => { ); }); + it('lowercases thrift package and service while preserving method case', () => { + expect(normalizeContractId('thrift::Billing.V1.OrderService/PlaceOrder')).toBe( + 'thrift::billing.v1.orderservice/PlaceOrder', + ); + }); + + it('preserves case for malformed thrift id with leading slash', () => { + expect(normalizeContractId('thrift::/PlaceOrder')).toBe('thrift::/PlaceOrder'); + }); + it('preserves case for malformed gRPC id with leading slash (no full-string lowercasing)', () => { expect(normalizeContractId('grpc::/MyPkg/DoThing')).toBe('grpc::/MyPkg/DoThing'); }); @@ -219,6 +229,26 @@ function makeGrpcContract( }; } +function makeThriftContract( + id: string, + role: 'provider' | 'consumer', + repo: string, + overrides: Partial = {}, +): StoredContract { + return { + contractId: id, + type: 'thrift', + role, + symbolUid: `uid-${repo}-${id}`, + symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` }, + symbolName: `fn-${id}`, + confidence: 0.9, + meta: {}, + repo, + ...overrides, + }; +} + // --------------------------------------------------------------------------- // buildProviderIndex // --------------------------------------------------------------------------- @@ -258,6 +288,18 @@ describe('runExactMatch — gRPC wildcard handling', () => { expect(unmatched).toHaveLength(2); }); + it('test_runExactMatch_skips_thrift_wildcard_contracts', () => { + const contracts: StoredContract[] = [ + makeThriftContract('thrift::billing.v1.OrderService/*', 'consumer', 'frontend'), + makeThriftContract('thrift::billing.v1.OrderService/*', 'provider', 'backend'), + ]; + + const { matched, unmatched } = runExactMatch(contracts); + + expect(matched).toHaveLength(0); + expect(unmatched).toHaveLength(2); + }); + it('test_runExactMatch_does_not_skip_http_wildcards', () => { const contracts: StoredContract[] = [ { @@ -402,6 +444,161 @@ describe('runWildcardMatch', () => { expect(matched).toHaveLength(1); expect(matched[0].contractId).toBe('grpc::com.example.UserService/*'); }); + + it('matches thrift fully-qualified service wildcard to a thrift provider method', () => { + const consumer = makeThriftContract( + 'thrift::billing.v1.OrderService/*', + 'consumer', + 'frontend', + ); + const provider = makeThriftContract( + 'thrift::billing.v1.OrderService/PlaceOrder', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched, remaining } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].type).toBe('thrift'); + expect(matched[0].from.repo).toBe('frontend'); + expect(matched[0].to.repo).toBe('backend'); + expect(remaining).toHaveLength(0); + }); + + it('matches bare thrift service wildcard to a package-qualified thrift provider', () => { + const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend'); + const provider = makeThriftContract( + 'thrift::billing.v1.OrderService/PlaceOrder', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].contractId).toBe('thrift::OrderService/*'); + }); + + it('does not match bare thrift service wildcard when multiple package-qualified services match', () => { + const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend'); + const billingProvider = makeThriftContract( + 'thrift::billing.v1.OrderService/PlaceOrder', + 'provider', + 'billing', + ); + const salesProvider = makeThriftContract( + 'thrift::sales.v1.OrderService/PlaceOrder', + 'provider', + 'sales', + ); + + const providerIndex = buildProviderIndex([billingProvider, salesProvider]); + const { matched, remaining } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(0); + expect(remaining).toEqual([consumer]); + }); + + it('keeps fully-qualified thrift service wildcard matching when same bare service appears elsewhere', () => { + const consumer = makeThriftContract( + 'thrift::billing.v1.OrderService/*', + 'consumer', + 'frontend', + ); + const billingProvider = makeThriftContract( + 'thrift::billing.v1.OrderService/PlaceOrder', + 'provider', + 'billing', + ); + const salesProvider = makeThriftContract( + 'thrift::sales.v1.OrderService/PlaceOrder', + 'provider', + 'sales', + ); + + const providerIndex = buildProviderIndex([billingProvider, salesProvider]); + const { matched, remaining } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].to.repo).toBe('billing'); + expect(remaining).toHaveLength(0); + }); + + it('matches bare thrift service method to a package-qualified thrift provider method', () => { + const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend'); + const provider = makeThriftContract( + 'thrift::billing.v1.OrderService/PlaceOrder', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched, unmatched } = runExactMatch([consumer, provider], providerIndex); + + expect(matched).toHaveLength(1); + expect(matched[0].type).toBe('thrift'); + expect(matched[0].matchType).toBe('exact'); + expect(matched[0].contractId).toBe('thrift::OrderService/PlaceOrder'); + expect(matched[0].from.repo).toBe('frontend'); + expect(matched[0].to.repo).toBe('backend'); + expect(unmatched).toHaveLength(0); + }); + + it('does not match bare thrift service method to a different provider method', () => { + const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend'); + const provider = makeThriftContract( + 'thrift::billing.v1.OrderService/GetOrderStatus', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched, unmatched } = runExactMatch([consumer, provider], providerIndex); + + expect(matched).toHaveLength(0); + expect(unmatched).toEqual([consumer, provider]); + }); + + it('does not match bare thrift service method when multiple package-qualified providers match', () => { + const consumer = makeThriftContract('thrift::OrderService/PlaceOrder', 'consumer', 'frontend'); + const billingProvider = makeThriftContract( + 'thrift::billing.v1.OrderService/PlaceOrder', + 'provider', + 'billing', + ); + const salesProvider = makeThriftContract( + 'thrift::sales.v1.OrderService/PlaceOrder', + 'provider', + 'sales', + ); + + const providerIndex = buildProviderIndex([salesProvider, billingProvider]); + const { matched, unmatched } = runExactMatch( + [consumer, salesProvider, billingProvider], + providerIndex, + ); + + expect(matched).toHaveLength(0); + expect(unmatched).toEqual([consumer, salesProvider, billingProvider]); + }); + + it('does not match a thrift wildcard to a gRPC provider', () => { + const consumer = makeThriftContract('thrift::OrderService/*', 'consumer', 'frontend'); + const provider = makeGrpcContract( + 'grpc::billing.v1.OrderService/PlaceOrder', + 'provider', + 'backend', + ); + + const providerIndex = buildProviderIndex([provider]); + const { matched, remaining } = runWildcardMatch([consumer], providerIndex); + + expect(matched).toHaveLength(0); + expect(remaining).toEqual([consumer]); + }); }); describe('buildNoisyContractFilter (via runExactMatch)', () => { diff --git a/gitnexus/test/unit/group/rust-workspace-extractor.test.ts b/gitnexus/test/unit/group/rust-workspace-extractor.test.ts index a9e277c36..3dd958ebf 100644 --- a/gitnexus/test/unit/group/rust-workspace-extractor.test.ts +++ b/gitnexus/test/unit/group/rust-workspace-extractor.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { extractRustWorkspaceLinks } from '../../../src/core/group/extractors/rust-workspace-extractor.js'; +import { _captureLogger } from '../../../src/core/logger.js'; describe('RustWorkspaceExtractor', () => { let tmpDir: string; @@ -229,19 +230,17 @@ describe('RustWorkspaceExtractor', () => { ['consumer', path.join(tmpDir, 'consumer')], ]); - const warnings: string[] = []; - const origWarn = console.warn; - console.warn = (...args: unknown[]) => { - warnings.push(String(args[0])); - }; + const cap = _captureLogger(); try { const result = await extractRustWorkspaceLinks(repos, repoPaths); - expect(warnings.some((w) => w.includes('duplicate crate name "shared"'))).toBe(true); + expect( + cap.records().some((r) => String(r.msg ?? '').includes('duplicate crate name "shared"')), + ).toBe(true); expect(result.links).toHaveLength(1); expect(result.links[0].from).toBe('a'); } finally { - console.warn = origWarn; + cap.restore(); } }); diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index a6b6376c0..88bc3af2d 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { syncGroup, stableRepoPoolId } from '../../../src/core/group/sync.js'; +import { _captureLogger } from '../../../src/core/logger.js'; import type { GroupConfig, StoredContract, @@ -22,6 +23,7 @@ describe('syncGroup', () => { detect: { http: true, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, @@ -229,9 +231,11 @@ describe('syncGroup', () => { detect: { http: true, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, + workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, }; @@ -264,6 +268,359 @@ describe('syncGroup', () => { expect(result.crossLinks).toHaveLength(1); }); + it('runs thrift wildcard matching after exact matching and returns wildcard remaining', async () => { + const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }); + const provider: StoredContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.9, + meta: {}, + repo: 'app/provider', + }; + const consumer: StoredContract = { + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-order-service', + symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' }, + symbolName: 'OrderClient', + confidence: 0.8, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [provider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('wildcard'); + expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/*'); + expect(result.crossLinks[0].from.repo).toBe('app/consumer'); + expect(result.crossLinks[0].to.repo).toBe('app/provider'); + expect(result.unmatched).toEqual([provider]); + }); + + it('keeps wildcard thrift links to multiple extracted IDL provider methods', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-wildcard-')); + fs.mkdirSync(path.join(tmpDir, 'idl'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) + OrderResponse GetOrder(1: string orderId) +}`, + ); + + try { + const { ThriftExtractor } = + await import('../../../src/core/group/extractors/thrift-extractor.js'); + const extractedProviders = ( + await new ThriftExtractor().extract(null, tmpDir, { + id: 'provider-repo', + path: 'app/provider', + repoPath: tmpDir, + storagePath: path.join(tmpDir, '.gitnexus'), + }) + ) + .filter((c) => c.role === 'provider') + .map( + (c): StoredContract => ({ + ...c, + repo: 'app/provider', + }), + ); + + const consumer: StoredContract = { + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'manifest::app/consumer::thrift::OrderService/*', + symbolRef: { filePath: 'group.yaml', name: 'OrderService' }, + symbolName: 'OrderService', + confidence: 1, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(makeConfig({}), { + extractorOverride: async () => [...extractedProviders, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(2); + expect(result.crossLinks.map((cl) => cl.to.symbolRef.name).sort()).toEqual([ + 'OrderService.GetOrder', + 'OrderService.PlaceOrder', + ]); + expect(new Set(result.crossLinks.map((cl) => cl.to.symbolUid)).size).toBe(2); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('matches weak thrift method consumers to namespace-qualified providers during sync', async () => { + const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }); + const provider: StoredContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'idl/order.thrift', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.85, + meta: {}, + repo: 'app/provider', + }; + const consumer: StoredContract = { + contractId: 'thrift::OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-place-order', + symbolRef: { filePath: 'src/BillingWorkflow.java', name: 'orderService.PlaceOrder' }, + symbolName: 'orderService.PlaceOrder', + confidence: 0.45, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [provider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('exact'); + expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/PlaceOrder'); + expect(result.crossLinks[0].from.repo).toBe('app/consumer'); + expect(result.crossLinks[0].to.repo).toBe('app/provider'); + expect(result.unmatched).toHaveLength(0); + }); + + it('keeps exact thrift links to extracted IDL and Java providers for same method', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-exact-')); + fs.mkdirSync(path.join(tmpDir, 'idl'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'src', 'main', 'java', 'example'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + fs.writeFileSync( + path.join(tmpDir, 'src', 'main', 'java', 'example', 'IfaceOrderHandler.java'), + `package example; + +class IfaceOrderHandler implements OrderService.Iface { + public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) { + return new PlaceOrderResponse(); + } +}`, + ); + + try { + const { ThriftExtractor } = + await import('../../../src/core/group/extractors/thrift-extractor.js'); + const extractedProviders = ( + await new ThriftExtractor().extract(null, tmpDir, { + id: 'provider-repo', + path: 'app/provider', + repoPath: tmpDir, + storagePath: path.join(tmpDir, '.gitnexus'), + }) + ) + .filter((c) => c.role === 'provider') + .map( + (c): StoredContract => ({ + ...c, + repo: 'app/provider', + }), + ); + + const consumer: StoredContract = { + contractId: 'thrift::OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + symbolUid: [ + 'source-scan::thrift', + 'consumer', + 'OrderService/PlaceOrder', + 'src/BillingWorkflow.java', + 'orderService.PlaceOrder', + ].join('::'), + symbolRef: { filePath: 'src/BillingWorkflow.java', name: 'orderService.PlaceOrder' }, + symbolName: 'orderService.PlaceOrder', + confidence: 0.45, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(makeConfig({}), { + extractorOverride: async () => [...extractedProviders, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(2); + expect(result.crossLinks.map((cl) => cl.to.symbolRef.filePath).sort()).toEqual([ + 'idl/order.thrift', + 'src/main/java/example/IfaceOrderHandler.java', + ]); + expect(new Set(result.crossLinks.map((cl) => cl.to.symbolUid)).size).toBe(2); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('extracts thrift contracts during real sync when thrift detection is enabled', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-')); + const storageDir = path.join(tmpDir, '.gitnexus'); + fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'idl'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'src'), { recursive: true }); + fs.mkdirSync(storageDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'services', 'billing', 'package.json'), '{}'); + fs.writeFileSync( + path.join(tmpDir, 'services', 'billing', 'src', 'BillingWorkflow.java'), + 'package example; class BillingWorkflow {}', + ); + fs.writeFileSync( + path.join(tmpDir, 'services', 'billing', 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + const config = makeConfig({ 'services/billing': 'billing-repo' }); + config.detect.http = false; + config.detect.thrift = true; + + const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js'); + const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined); + const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined); + + try { + const result = await syncGroup(config, { + resolveRepoHandle: async (_name, groupPath) => ({ + id: 'billing-repo', + path: groupPath, + repoPath: tmpDir, + storagePath: storageDir, + }), + skipWrite: true, + }); + + expect(result.missingRepos).toHaveLength(0); + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0]).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + repo: 'services/billing', + service: 'services/billing', + symbolRef: { + filePath: 'services/billing/idl/order.thrift', + name: 'OrderService.PlaceOrder', + }, + }); + expect(initSpy).toHaveBeenCalledWith('billing-repo', path.join(storageDir, 'lbug')); + expect(closeSpy).toHaveBeenCalledWith('billing-repo'); + } finally { + initSpy.mockRestore(); + closeSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('does not extract thrift contracts during real sync when thrift detection is disabled', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-off-')); + const storageDir = path.join(tmpDir, '.gitnexus'); + fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'idl'), { recursive: true }); + fs.mkdirSync(storageDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'services', 'billing', 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + const config = makeConfig({ 'services/billing': 'billing-repo' }); + config.detect.http = false; + config.detect.thrift = false; + + const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js'); + const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined); + const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined); + + try { + const result = await syncGroup(config, { + resolveRepoHandle: async (_name, groupPath) => ({ + id: 'billing-repo', + path: groupPath, + repoPath: tmpDir, + storagePath: storageDir, + }), + skipWrite: true, + }); + + expect(result.missingRepos).toHaveLength(0); + expect(result.contracts).toHaveLength(0); + } finally { + initSpy.mockRestore(); + closeSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('dedupes duplicate wildcard cross-links during sync', async () => { + const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }); + const provider: StoredContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.9, + meta: {}, + repo: 'app/provider', + }; + const duplicateProvider: StoredContract = { + ...provider, + confidence: 0.7, + }; + const consumer: StoredContract = { + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-order-service', + symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' }, + symbolName: 'OrderClient', + confidence: 0.8, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [provider, duplicateProvider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('wildcard'); + }); + it('manifest links referencing unknown repos still produce cross-links via synthetic UIDs', async () => { const links: GroupManifestLink[] = [ { @@ -285,16 +642,16 @@ describe('syncGroup', () => { detect: { http: true, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, + workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, }; - const warnings: string[] = []; - const origWarn = console.warn; - console.warn = (msg: string) => warnings.push(String(msg)); + const cap = _captureLogger(); try { const result = await syncGroup(config, { extractorOverride: async () => [], @@ -306,9 +663,9 @@ describe('syncGroup', () => { expect(result.crossLinks[0].to.symbolUid).toBe( 'manifest::app/dangling::http::POST::/api/missing', ); - expect(warnings.some((w) => w.includes('app/dangling'))).toBe(true); + expect(cap.records().some((r) => String(r.msg ?? '').includes('app/dangling'))).toBe(true); } finally { - console.warn = origWarn; + cap.restore(); } }); @@ -350,6 +707,7 @@ describe('syncGroup', () => { detect: { http: false, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, @@ -506,6 +864,7 @@ describe('syncGroup', () => { detect: { http: false, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, diff --git a/gitnexus/test/unit/group/thrift-extractor.test.ts b/gitnexus/test/unit/group/thrift-extractor.test.ts new file mode 100644 index 000000000..ca7045543 --- /dev/null +++ b/gitnexus/test/unit/group/thrift-extractor.test.ts @@ -0,0 +1,651 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + ThriftExtractor, + buildThriftContext, + thriftMethodContractId, + thriftServiceContractId, +} from '../../../src/core/group/extractors/thrift-extractor.js'; +import type { RepoHandle } from '../../../src/core/group/types.js'; + +describe('ThriftExtractor', () => { + let tmpDir: string; + let extractor: ThriftExtractor; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-thrift-')); + extractor = new ThriftExtractor(); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(relPath: string, content: string): void { + const full = path.join(tmpDir, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + + const makeRepo = (repoPath: string): RepoHandle => ({ + id: 'test-repo', + path: 'test/app', + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + }); + + it('test_extract_thrift_single_method_returns_idl_provider', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toHaveLength(1); + expect(contracts[0]).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolName: 'OrderService.PlaceOrder', + confidence: 0.85, + meta: { + namespace: 'billing.v1', + service: 'OrderService', + method: 'PlaceOrder', + source: 'thrift_idl', + }, + }); + expect(contracts[0].symbolRef).toEqual({ + filePath: 'idl/order.thrift', + name: 'OrderService.PlaceOrder', + }); + }); + + it('test_extract_thrift_multiple_services_and_methods_returns_all', async () => { + writeFile( + 'contracts/orders.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) + OrderStatus GetOrderStatus(1: string orderId) +} + +service InvoiceService { + Invoice CreateInvoice(1: string orderId) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts.map((c) => c.contractId).sort()).toEqual([ + 'thrift::billing.v1.InvoiceService/CreateInvoice', + 'thrift::billing.v1.OrderService/GetOrderStatus', + 'thrift::billing.v1.OrderService/PlaceOrder', + ]); + }); + + it('test_extract_thrift_prefers_java_namespace_over_other_namespaces', async () => { + writeFile( + 'order.thrift', + `namespace py billing_python.v1 +namespace java billing.v1 +namespace go billinggo + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts[0].contractId).toBe('thrift::billing.v1.OrderService/PlaceOrder'); + expect(contracts[0].meta.namespace).toBe('billing.v1'); + }); + + it('test_extract_thrift_uses_first_non_java_namespace_when_java_missing', async () => { + writeFile( + 'order.thrift', + `namespace py billing_python.v1 +namespace go billinggo + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts[0].contractId).toBe('thrift::billing_python.v1.OrderService/PlaceOrder'); + expect(contracts[0].meta.namespace).toBe('billing_python.v1'); + }); + + it('test_extract_thrift_without_namespace_uses_service_only', async () => { + writeFile( + 'order.thrift', + `service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts[0].contractId).toBe('thrift::OrderService/PlaceOrder'); + expect(contracts[0].meta.namespace).toBe(''); + }); + + it('test_extract_thrift_ignores_braces_inside_comments_and_strings', async () => { + writeFile( + 'idl/tricky.thrift', + `namespace java billing.v1 + +service OrderService { + // A comment with } should not close the service. + /* A block comment with { and } should not affect depth. */ + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) + const string NOTE = "literal with } and { braces" + OrderStatus GetOrderStatus(1: string orderId) +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts.map((c) => c.symbolName).sort()).toEqual([ + 'OrderService.GetOrderStatus', + 'OrderService.PlaceOrder', + ]); + }); + + it('test_extract_thrift_malformed_unclosed_service_is_skipped', async () => { + writeFile( + 'idl/broken.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +`, + ); + + await expect(extractor.extract(null, tmpDir, makeRepo(tmpDir))).resolves.toEqual([]); + }); + + it('test_extract_repo_without_thrift_returns_empty', async () => { + writeFile('src/index.ts', 'console.log("hello")'); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toEqual([]); + }); + + it('test_extract_java_thrift_consumers_from_iface_client_and_service_fields', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/BillingWorkflow.java', + `package example; + +class BillingWorkflow { + private OrderService.Iface orderService; + private OrderService.Client orderClient; + private OrderService generatedOrderService; + + void submit(PlaceOrderRequest request) throws Exception { + orderService.PlaceOrder(request); + orderClient.PlaceOrder(request); + generatedOrderService.PlaceOrder(request); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts + .filter((c) => c.role === 'consumer') + .sort((a, b) => a.symbolName.localeCompare(b.symbolName)); + + expect(consumers).toHaveLength(3); + expect(consumers.map((c) => c.symbolName)).toEqual([ + 'generatedOrderService.PlaceOrder', + 'orderClient.PlaceOrder', + 'orderService.PlaceOrder', + ]); + for (const contract of consumers) { + expect(contract).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + confidence: 0.75, + meta: { + namespace: 'billing.v1', + service: 'OrderService', + method: 'PlaceOrder', + source: 'java_thrift_consumer', + }, + }); + expect(contract.symbolRef.filePath).toBe('src/main/java/example/BillingWorkflow.java'); + } + }); + + it('test_extract_java_thrift_consumers_from_this_field_access', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/BillingWorkflow.java', + `package example; + +class BillingWorkflow { + private OrderService.Client orderClient; + + void submit(PlaceOrderRequest request) throws Exception { + this.orderClient.PlaceOrder(request); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers).toHaveLength(1); + expect(consumers[0]).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + symbolName: 'orderClient.PlaceOrder', + confidence: 0.75, + meta: { + namespace: 'billing.v1', + service: 'OrderService', + method: 'PlaceOrder', + source: 'java_thrift_consumer', + }, + }); + }); + + it('test_extract_java_thrift_consumers_from_fully_qualified_generated_types', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/BillingWorkflow.java', + `package example; + +class BillingWorkflow { + private billing.v1.OrderService.Iface orderService; + private billing.v1.OrderService.Client orderClient; + + void submit(PlaceOrderRequest request) throws Exception { + orderService.PlaceOrder(request); + orderClient.PlaceOrder(request); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts + .filter((c) => c.role === 'consumer') + .sort((a, b) => a.symbolName.localeCompare(b.symbolName)); + + expect(consumers).toHaveLength(2); + expect(consumers.map((c) => c.symbolName)).toEqual([ + 'orderClient.PlaceOrder', + 'orderService.PlaceOrder', + ]); + expect(new Set(consumers.map((c) => c.contractId))).toEqual( + new Set(['thrift::billing.v1.OrderService/PlaceOrder']), + ); + }); + + it('test_extract_java_thrift_consumers_from_local_variables', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/BillingWorker.java', + `package example; + +class BillingWorker { + void submit(OrderService.Iface iface, OrderService.Client client, OrderService service) throws Exception { + OrderService.Iface orderService = iface; + OrderService.Client orderClient = client; + OrderService generatedOrderService = service; + + orderService.PlaceOrder(new PlaceOrderRequest()); + orderClient.PlaceOrder(new PlaceOrderRequest()); + generatedOrderService.PlaceOrder(new PlaceOrderRequest()); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect(consumers.map((c) => c.symbolName).sort()).toEqual([ + 'generatedOrderService.PlaceOrder', + 'orderClient.PlaceOrder', + 'orderService.PlaceOrder', + ]); + expect(new Set(consumers.map((c) => c.contractId))).toEqual( + new Set(['thrift::billing.v1.OrderService/PlaceOrder']), + ); + }); + + it('test_extract_java_thrift_consumers_resolve_receiver_by_nearest_scope', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +} + +service InvoiceService { + Invoice CreateInvoice(1: string orderId) +}`, + ); + writeFile( + 'src/main/java/example/BillingWorker.java', + `package example; + +class BillingWorker { + void submitOrder(OrderService.Iface client, PlaceOrderRequest request) throws Exception { + client.PlaceOrder(request); + } + + void submitInvoice() throws Exception { + InvoiceService.Client client = new InvoiceService.Client(null); + client.CreateInvoice("order-1"); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const consumers = contracts + .filter((c) => c.role === 'consumer') + .sort((a, b) => a.contractId.localeCompare(b.contractId)); + + expect(consumers.map((c) => c.contractId)).toEqual([ + 'thrift::billing.v1.InvoiceService/CreateInvoice', + 'thrift::billing.v1.OrderService/PlaceOrder', + ]); + expect(consumers.map((c) => c.symbolName).sort()).toEqual([ + 'client.CreateInvoice', + 'client.PlaceOrder', + ]); + }); + + it('test_extract_java_thrift_providers_from_iface_and_service_implements', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/IfaceOrderHandler.java', + `package example; + +class IfaceOrderHandler implements OrderService.Iface { + public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) { + return new PlaceOrderResponse(); + } +}`, + ); + writeFile( + 'src/main/java/example/GeneratedOrderHandler.java', + `package example; + +class GeneratedOrderHandler implements OrderService { + public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) { + return new PlaceOrderResponse(); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts + .filter((c) => c.meta.source === 'java_thrift_provider') + .sort((a, b) => a.symbolRef.filePath.localeCompare(b.symbolRef.filePath)); + + expect(providers).toHaveLength(2); + for (const contract of providers) { + expect(contract).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolName: 'OrderService.PlaceOrder', + confidence: 0.8, + meta: { + namespace: 'billing.v1', + service: 'OrderService', + method: 'PlaceOrder', + source: 'java_thrift_provider', + }, + }); + } + }); + + it('test_extract_thrift_source_scan_contracts_have_stable_distinct_symbol_uids', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/IfaceOrderHandler.java', + `package example; + +class IfaceOrderHandler implements OrderService.Iface { + public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) { + return new PlaceOrderResponse(); + } +}`, + ); + + const first = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const second = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = first + .filter((c) => c.role === 'provider') + .sort((a, b) => a.symbolRef.filePath.localeCompare(b.symbolRef.filePath)); + const repeatedProviders = second + .filter((c) => c.role === 'provider') + .sort((a, b) => a.symbolRef.filePath.localeCompare(b.symbolRef.filePath)); + + expect(providers).toHaveLength(2); + expect(providers.map((c) => c.symbolUid)).toEqual(repeatedProviders.map((c) => c.symbolUid)); + expect(providers.every((c) => c.symbolUid.length > 0)).toBe(true); + expect(new Set(providers.map((c) => c.symbolUid)).size).toBe(2); + expect(providers.every((c) => !c.symbolUid.includes('::thrift::billing.v1'))).toBe(true); + }); + + it('test_extract_java_thrift_providers_from_fully_qualified_generated_iface', async () => { + writeFile( + 'idl/order.thrift', + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + writeFile( + 'src/main/java/example/IfaceOrderHandler.java', + `package example; + +class IfaceOrderHandler implements billing.v1.OrderService.Iface { + public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) { + return new PlaceOrderResponse(); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + const providers = contracts.filter((c) => c.meta.source === 'java_thrift_provider'); + + expect(providers).toHaveLength(1); + expect(providers[0]).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolName: 'OrderService.PlaceOrder', + confidence: 0.8, + meta: { + namespace: 'billing.v1', + service: 'OrderService', + method: 'PlaceOrder', + source: 'java_thrift_provider', + }, + }); + }); + + it('test_extract_java_thrift_consumer_without_idl_emits_weak_method_contract', async () => { + writeFile( + 'src/main/java/example/BillingWorkflow.java', + `package example; + +class BillingWorkflow { + private OrderService.Iface orderService; + + void submit(PlaceOrderRequest request) throws Exception { + orderService.PlaceOrder(request); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toHaveLength(1); + expect(contracts[0]).toMatchObject({ + contractId: 'thrift::OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + symbolName: 'orderService.PlaceOrder', + confidence: 0.45, + meta: { + service: 'OrderService', + method: 'PlaceOrder', + source: 'java_thrift_consumer_weak', + }, + }); + expect(contracts[0].symbolRef.filePath).toBe('src/main/java/example/BillingWorkflow.java'); + }); + + it('test_extract_java_thrift_direct_service_consumer_without_idl_returns_empty', async () => { + writeFile( + 'src/main/java/example/PaymentWorkflow.java', + `package example; + +class PaymentWorkflow { + private PaymentService paymentService; + + void submit() { + paymentService.charge(); + } +}`, + ); + + const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir)); + + expect(contracts).toEqual([]); + }); +}); + +describe('buildThriftContext', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-thrift-context-')); + }); + + afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); + }); + + it('test_buildThriftContext_parses_namespace_service_methods_and_path', async () => { + await fsp.mkdir(path.join(tmpDir, 'idl'), { recursive: true }); + await fsp.writeFile( + path.join(tmpDir, 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) + OrderStatus GetOrderStatus(1: string orderId) +}`, + ); + + const context = await buildThriftContext(tmpDir); + + expect(context.namespacesByThrift.get('idl/order.thrift')).toBe('billing.v1'); + expect(context.servicesByName.get('OrderService')).toEqual([ + { + namespace: 'billing.v1', + serviceName: 'OrderService', + methods: ['PlaceOrder', 'GetOrderStatus'], + thriftPath: 'idl/order.thrift', + }, + ]); + }); + + it('test_buildThriftContext_without_files_returns_empty_maps', async () => { + const context = await buildThriftContext(tmpDir); + + expect(context.namespacesByThrift.size).toBe(0); + expect(context.servicesByName.size).toBe(0); + }); +}); + +describe('Thrift contract id helpers', () => { + it('test_thriftMethodContractId_with_namespace', () => { + expect(thriftMethodContractId('billing.v1', 'OrderService', 'PlaceOrder')).toBe( + 'thrift::billing.v1.OrderService/PlaceOrder', + ); + }); + + it('test_thriftMethodContractId_without_namespace', () => { + expect(thriftMethodContractId('', 'OrderService', 'PlaceOrder')).toBe( + 'thrift::OrderService/PlaceOrder', + ); + }); + + it('test_thriftServiceContractId_with_namespace', () => { + expect(thriftServiceContractId('billing.v1', 'OrderService')).toBe( + 'thrift::billing.v1.OrderService/*', + ); + }); + + it('test_thriftServiceContractId_without_namespace', () => { + expect(thriftServiceContractId('', 'OrderService')).toBe('thrift::OrderService/*'); + }); +}); diff --git a/gitnexus/test/unit/group/types.test.ts b/gitnexus/test/unit/group/types.test.ts index df952e598..025ed4bbb 100644 --- a/gitnexus/test/unit/group/types.test.ts +++ b/gitnexus/test/unit/group/types.test.ts @@ -21,6 +21,7 @@ describe('Group types', () => { detect: { http: true, grpc: true, + thrift: true, topics: true, shared_libs: true, embedding_fallback: true, @@ -63,6 +64,41 @@ describe('Group types', () => { }); }); + it('ExtractedContract accepts thrift contract type', () => { + const contract: ExtractedContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-thrift', + symbolRef: { filePath: 'idl/order.thrift', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.9, + meta: {}, + }; + expect(contract.type).toBe('thrift'); + }); + + it('DetectConfig includes thrift toggle', () => { + const config: GroupConfig = { + version: 1, + name: 'company', + description: 'All company microservices', + repos: { orders: 'orders-repo' }, + links: [], + packages: {}, + detect: { + http: true, + grpc: true, + thrift: true, + topics: true, + shared_libs: true, + embedding_fallback: true, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }; + expect(config.detect.thrift).toBe(true); + }); + it('CrossLink stores match metadata', () => { const link: CrossLink = { from: { diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index de32eb8d9..b4e5cdca1 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; @@ -8,6 +8,7 @@ import { loadIgnoreRules, createIgnoreFilter, } from '../../src/config/ignore-service.js'; +import { _captureLogger } from '../../src/core/logger.js'; describe('shouldIgnorePath', () => { describe('version control directories', () => { @@ -574,13 +575,13 @@ describe('loadIgnoreRules — error handling', () => { await fs.writeFile(gitignorePath, 'data/\n'); await fs.chmod(gitignorePath, 0o000); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cap = _captureLogger(); const result = await loadIgnoreRules(tmpDir); // Should still return (null or partial), not throw expect(result).toBeNull(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('.gitignore')); + expect(cap.records().some((r) => String(r.msg ?? '').includes('.gitignore'))).toBe(true); - warnSpy.mockRestore(); + cap.restore(); await fs.chmod(gitignorePath, 0o644); await fs.unlink(gitignorePath); }, diff --git a/gitnexus/test/unit/lbug-checkpoint.test.ts b/gitnexus/test/unit/lbug-checkpoint.test.ts new file mode 100644 index 000000000..5b68ee9bd --- /dev/null +++ b/gitnexus/test/unit/lbug-checkpoint.test.ts @@ -0,0 +1,88 @@ +/** + * Structural + behavioural tests for the WAL-flush / close helpers (#1376). + * + * After the review-driven refactor, the module exposes two layers: + * - flushWAL — CHECKPOINT only (connection stays open) + * - safeClose — flushWAL + conn.close + db.close + * + * closeLbug delegates to safeClose for the CHECKPOINT + close step and + * then resets module-level state (currentDbPath, ftsLoaded, etc.). + * + * The structural tests read the adapter source and verify delegation + * contracts so a future refactor that inlines close logic is caught. + * + * The behavioural tests import flushWAL directly and exercise the + * runtime null-guard path (conn is null at module load) so a future + * refactor that accidentally throws is caught immediately. + */ +import { beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { flushWAL } from '../../src/core/lbug/lbug-adapter.js'; + +describe('flushWAL / safeClose — consolidation guard (#1376)', () => { + let adapterSource: string; + + beforeAll(async () => { + adapterSource = await fs.readFile( + path.join(__dirname, '..', '..', 'src', 'core', 'lbug', 'lbug-adapter.ts'), + 'utf-8', + ); + }); + + it('exports flushWAL (CHECKPOINT-only helper)', () => { + expect(adapterSource).toMatch(/export const flushWAL/); + }); + + it('exports safeClose (CHECKPOINT + close helper)', () => { + expect(adapterSource).toMatch(/export const safeClose/); + }); + + it('safeClose delegates to flushWAL for the CHECKPOINT step', () => { + const safeCloseBody = adapterSource.slice(adapterSource.indexOf('export const safeClose')); + expect(safeCloseBody).toMatch(/await flushWAL\(\)/); + }); + + it('closeLbug delegates to safeClose instead of inlining conn.close/db.close', () => { + const closeLbugBody = adapterSource.slice(adapterSource.indexOf('export const closeLbug')); + expect(closeLbugBody).toMatch(/await safeClose\(\)/); + // closeLbug must NOT contain its own conn.close() or db.close() — those + // live exclusively inside safeClose now. + const closeLbugBlock = closeLbugBody.slice(0, closeLbugBody.indexOf('export const', 1) >>> 0); + expect(closeLbugBlock).not.toMatch(/conn\.close\(\)/); + expect(closeLbugBlock).not.toMatch(/db\.close\(\)/); + }); + + it('flushWAL is the only place that issues conn.query(CHECKPOINT)', () => { + const matches = adapterSource.match(/conn\.query\('CHECKPOINT'\)/g) ?? []; + expect(matches.length).toBe(1); + }); + + it('conn.close() only appears inside safeClose (with eslint-disable)', () => { + // Every conn.close() in the adapter must live inside safeClose, guarded + // by the eslint-disable comment. Count occurrences to catch leaks. + const matches = adapterSource.match(/await conn\.close\(\)/g) ?? []; + expect(matches.length).toBe(1); + }); + + it('db.close() only appears inside safeClose (with eslint-disable)', () => { + const matches = adapterSource.match(/await db\.close\(\)/g) ?? []; + expect(matches.length).toBe(1); + }); +}); + +// Behavioural tests — exercise flushWAL at runtime rather than just +// grepping source text. At module load `conn` is null, so these hit +// the early-return guard without needing a real LadybugDB instance. +describe('flushWAL — runtime behaviour', () => { + it('resolves without error when no connection is open', async () => { + // conn is null at module load — flushWAL must not throw. + await expect(flushWAL()).resolves.toBeUndefined(); + }); + + it('can be called repeatedly without throwing (idempotent)', async () => { + await flushWAL(); + await flushWAL(); + // No assertion needed beyond "did not throw". + }); +}); diff --git a/gitnexus/test/unit/logger.test.ts b/gitnexus/test/unit/logger.test.ts new file mode 100644 index 000000000..7d92d40c5 --- /dev/null +++ b/gitnexus/test/unit/logger.test.ts @@ -0,0 +1,279 @@ +/** + * Unit tests for src/core/logger.ts. + * + * Asserts the wiring rather than re-deriving pino's output format: + * - createLogger returns level-method API + * - debugEnvVar opt promotes level to 'debug' when env truthy + * - destination opt redirects output (test-capture pattern) + * - Error.message === undefined does not throw + * - CR/LF/U+2028/ANSI in field values produce a single NDJSON line + * + * The pretty-printing branch is exercised indirectly: VITEST=true (which + * vitest sets automatically) means shouldUsePretty() returns false, so + * tests run with raw NDJSON — exactly the operator-CI behavior. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + createLogger, + logger, + MemoryWritable, + _captureLogger, + _tryBuildPrettyTransport, + _resetPrettyAvailableCache, + flushLoggerSync, +} from '../../src/core/logger.js'; + +describe('createLogger — API surface', () => { + it('returns an object with the standard level methods', () => { + const dest = new MemoryWritable(); + const log = createLogger('test', { destination: dest }); + expect(typeof log.warn).toBe('function'); + expect(typeof log.error).toBe('function'); + expect(typeof log.debug).toBe('function'); + expect(typeof log.info).toBe('function'); + expect(typeof log.fatal).toBe('function'); + expect(typeof log.trace).toBe('function'); + }); + + it('default singleton logger exposes the same API', () => { + expect(typeof logger.warn).toBe('function'); + expect(typeof logger.error).toBe('function'); + }); +}); + +describe('createLogger — debugEnvVar gating', () => { + const ENV = 'TEST_PINO_DEBUG_VAR'; + + beforeEach(() => { + delete process.env[ENV]; + }); + + afterEach(() => { + delete process.env[ENV]; + }); + + it('without debugEnvVar, .debug() emits nothing (default info level)', () => { + const dest = new MemoryWritable(); + const log = createLogger('t', { destination: dest }); + log.debug('should not appear'); + expect(dest.records()).toEqual([]); + }); + + it('with debugEnvVar set but env unset, .debug() emits nothing', () => { + const dest = new MemoryWritable(); + const log = createLogger('t', { debugEnvVar: ENV, destination: dest }); + log.debug('should not appear'); + expect(dest.records()).toEqual([]); + }); + + it('with debugEnvVar set and env truthy, .debug() emits a record', () => { + process.env[ENV] = '1'; + const dest = new MemoryWritable(); + const log = createLogger('t', { debugEnvVar: ENV, destination: dest }); + log.debug({ key: 'value' }, 'debug-msg'); + const records = dest.records() as Array>; + expect(records.length).toBe(1); + expect(records[0].msg).toBe('debug-msg'); + expect(records[0].key).toBe('value'); + expect(records[0].name).toBe('t'); + }); + + it('treats env values "0", "false", "no", "off" as falsy', () => { + for (const falsy of ['0', 'false', 'FALSE', 'no', 'off', '']) { + process.env[ENV] = falsy; + const dest = new MemoryWritable(); + const log = createLogger('t', { debugEnvVar: ENV, destination: dest }); + log.debug('hidden'); + expect(dest.records(), `value=${JSON.stringify(falsy)}`).toEqual([]); + } + }); +}); + +describe('createLogger — structured output safety', () => { + it('captures .warn output as parseable NDJSON in destination', () => { + const dest = new MemoryWritable(); + const log = createLogger('cap', { destination: dest }); + log.warn({ groupDir: '/tmp/x', attempts: 3 }, 'gave up'); + const records = dest.records() as Array>; + expect(records.length).toBe(1); + expect(records[0].msg).toBe('gave up'); + expect(records[0].name).toBe('cap'); + expect(records[0].groupDir).toBe('/tmp/x'); + expect(records[0].attempts).toBe(3); + expect(records[0].level).toBe(40); // pino's numeric warn level + }); + + it('handles Error with undefined message without throwing', () => { + const dest = new MemoryWritable(); + const log = createLogger('cap', { destination: dest }); + const err = new Error('original'); + Object.assign(err, { message: undefined }); + expect(() => log.warn({ err }, 'with bad error')).not.toThrow(); + const records = dest.records(); + expect(records.length).toBe(1); + }); + + it('CR/LF in a string field stays inside one NDJSON record', () => { + const dest = new MemoryWritable(); + const log = createLogger('cap', { destination: dest }); + const evil = '/tmp/group\r\n2026-01-01 [bridge-db] FAKE INJECTED LINE'; + log.warn({ groupDir: evil }, 'msg'); + // Exactly one record. The internal \r\n is JSON-escaped, not a record boundary. + expect(dest.records().length).toBe(1); + // Raw text has trailing newline as record terminator — count of \n == 1. + expect( + dest + .text() + .split('\n') + .filter((l) => l.length > 0).length, + ).toBe(1); + }); + + it('U+2028 / U+2029 in a string field stays inside one NDJSON record', () => { + const dest = new MemoryWritable(); + const log = createLogger('cap', { destination: dest }); + const evil = 'before
after
more'; + log.warn({ field: evil }, 'msg'); + // Same record-count invariant. JSON.parse round-trips the codepoints. + expect(dest.records().length).toBe(1); + const rec = dest.records()[0] as Record; + expect(rec.field).toBe(evil); + }); + + it('ANSI escape sequence in a string field stays inside one NDJSON record', () => { + const dest = new MemoryWritable(); + const log = createLogger('cap', { destination: dest }); + const ansi = 'RED'; + log.warn({ msg2: ansi }, 'msg'); + expect(dest.records().length).toBe(1); + }); +}); + +describe('_captureLogger — lifecycle', () => { + it('captures records emitted via the default logger singleton', () => { + const cap = _captureLogger(); + try { + logger.warn({ k: 'v' }, 'captured'); + const recs = cap.records(); + expect(recs.length).toBe(1); + expect(recs[0].msg).toBe('captured'); + expect(recs[0].k).toBe('v'); + } finally { + cap.restore(); + } + }); + + it('restore() stops further writes from reaching the captured stream', () => { + const cap = _captureLogger(); + logger.warn('first'); + cap.restore(); + // After restore, the singleton routes back to the real (stderr) + // destination. The captured stream should still hold only the first + // record — the second logger.warn must not show up here. + logger.warn('second'); + const recs = cap.records(); + expect(recs.length).toBe(1); + expect(recs[0].msg).toBe('first'); + }); + + it('throws when called twice without restore() — guards against silent state corruption', () => { + const cap = _captureLogger(); + try { + expect(() => _captureLogger()).toThrow(/previous capture is still active/); + } finally { + cap.restore(); + } + }); + + it('can re-capture after restore()', () => { + const cap1 = _captureLogger(); + cap1.restore(); + const cap2 = _captureLogger(); + try { + logger.warn('after-recapture'); + expect(cap2.records().some((r) => r.msg === 'after-recapture')).toBe(true); + } finally { + cap2.restore(); + } + }); +}); + +describe('_tryBuildPrettyTransport — pino-pretty availability probe', () => { + beforeEach(() => { + _resetPrettyAvailableCache(); + }); + + afterEach(() => { + _resetPrettyAvailableCache(); + }); + + it('returns transport options when pino-pretty resolves (the production install path)', () => { + // pino-pretty is a runtime dep (PR #1336 Codex P1 fix), so under any + // normal vitest run the probe should succeed and yield a target + + // destination:2 transport spec. This is the happy path. + const transport = _tryBuildPrettyTransport(); + expect(transport).toBeDefined(); + // Pino accepts a single transport object or a multi-target shape; we + // emit the single-target form, so narrow before asserting. + if (transport && typeof transport === 'object' && 'target' in transport) { + expect(transport.target).toBe('pino-pretty'); + expect(transport.options).toMatchObject({ + destination: 2, + colorize: true, + }); + } else { + throw new Error('expected single-target transport options shape with target=pino-pretty'); + } + }); + + it('memoizes the resolve result — repeat calls do not re-probe', () => { + // Probe once, then again — both should return the same shape and the + // cache should make the second call zero-cost. We can't observe the + // resolve count from outside, but we can assert the second call's + // result is structurally identical and that the warning is not + // double-emitted (covered by the next test). + const first = _tryBuildPrettyTransport(); + const second = _tryBuildPrettyTransport(); + expect(second).toEqual(first); + }); + + it('flushLoggerSync is callable without throwing whether or not the destination has been used', () => { + // The contract: shutdown handlers can call this unconditionally before + // process.exit and it is safe even when no logger has emitted yet. + expect(() => flushLoggerSync()).not.toThrow(); + // After a logger emit, still safe to call. + logger.info('warm the destination'); + expect(() => flushLoggerSync()).not.toThrow(); + }); + + it('emits at most one stderr warning across many calls when pino-pretty is missing', () => { + // Simulate the missing-module path by stubbing process.stderr.write + // and forcing the cache to "not available". This validates the + // one-time-warning contract: 100 calls produce 0 or 1 warning lines, + // never 100. + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + // Inject negative cache state by monkey-patching require.resolve + // would be brittle. Instead, exercise the actual cache: after a + // successful resolve, no warning fires. Then for the missing path, + // we rely on the structural guarantee that warningEmitted state is + // module-level and only one branch can fire it. This test asserts + // the upper bound: even under heavy call volume, stderr writes + // attributable to the probe never exceed 1 per process lifetime. + for (let i = 0; i < 100; i++) { + _tryBuildPrettyTransport(); + } + const probeWarnings = stderrSpy.mock.calls.filter(([chunk]) => { + const s = typeof chunk === 'string' ? chunk : chunk.toString(); + return s.includes('pino-pretty unavailable'); + }); + // pino-pretty IS installed in the test env, so the warning never + // fires. The assertion is "<= 1" rather than "=== 0" so the test + // also passes in a future env where pino-pretty is intentionally + // stripped (the contract still holds). + expect(probeWarnings.length).toBeLessThanOrEqual(1); + } finally { + stderrSpy.mockRestore(); + } + }); +}); diff --git a/gitnexus/test/unit/max-file-size.test.ts b/gitnexus/test/unit/max-file-size.test.ts index 074e148e7..569b41eab 100644 --- a/gitnexus/test/unit/max-file-size.test.ts +++ b/gitnexus/test/unit/max-file-size.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { DEFAULT_MAX_FILE_SIZE_BYTES, MAX_FILE_SIZE_UPPER_BOUND_BYTES, @@ -6,15 +6,16 @@ import { getMaxFileSizeBannerMessage, _resetMaxFileSizeWarnings, } from '../../src/core/ingestion/utils/max-file-size.js'; +import { _captureLogger } from '../../src/core/logger.js'; describe('getMaxFileSizeBytes', () => { const ORIGINAL = process.env.GITNEXUS_MAX_FILE_SIZE; - let warnSpy: ReturnType; + let cap: ReturnType; beforeEach(() => { delete process.env.GITNEXUS_MAX_FILE_SIZE; _resetMaxFileSizeWarnings(); - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + cap = _captureLogger(); }); afterEach(() => { @@ -23,43 +24,43 @@ describe('getMaxFileSizeBytes', () => { } else { process.env.GITNEXUS_MAX_FILE_SIZE = ORIGINAL; } - warnSpy.mockRestore(); + cap.restore(); }); it('returns the default when the env var is unset', () => { expect(getMaxFileSizeBytes()).toBe(DEFAULT_MAX_FILE_SIZE_BYTES); - expect(warnSpy).not.toHaveBeenCalled(); + expect(cap.records().length).toBe(0); }); it('parses a positive integer value as KB', () => { process.env.GITNEXUS_MAX_FILE_SIZE = '1024'; expect(getMaxFileSizeBytes()).toBe(1024 * 1024); - expect(warnSpy).not.toHaveBeenCalled(); + expect(cap.records().length).toBe(0); }); it('clamps values above the tree-sitter ceiling', () => { - // One KB above the 32 MB ceiling. const aboveCeilingKb = MAX_FILE_SIZE_UPPER_BOUND_BYTES / 1024 + 1; process.env.GITNEXUS_MAX_FILE_SIZE = String(aboveCeilingKb); expect(getMaxFileSizeBytes()).toBe(MAX_FILE_SIZE_UPPER_BOUND_BYTES); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toContain('clamping'); + const records = cap.records(); + expect(records.length).toBe(1); + expect(String(records[0].msg)).toContain('clamping'); }); it.each(['abc', '0', '-512', '1.5', 'NaN', ''])( 'falls back to the default and warns on invalid value %s', (raw) => { if (raw === '') { - // Empty string is treated as unset by the util (raw falsy check). process.env.GITNEXUS_MAX_FILE_SIZE = raw; expect(getMaxFileSizeBytes()).toBe(DEFAULT_MAX_FILE_SIZE_BYTES); - expect(warnSpy).not.toHaveBeenCalled(); + expect(cap.records().length).toBe(0); return; } process.env.GITNEXUS_MAX_FILE_SIZE = raw; expect(getMaxFileSizeBytes()).toBe(DEFAULT_MAX_FILE_SIZE_BYTES); - expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0][0]).toContain('must be a positive integer'); + const records = cap.records(); + expect(records.length).toBe(1); + expect(String(records[0].msg)).toContain('must be a positive integer'); }, ); @@ -68,7 +69,7 @@ describe('getMaxFileSizeBytes', () => { getMaxFileSizeBytes(); getMaxFileSizeBytes(); getMaxFileSizeBytes(); - expect(warnSpy).toHaveBeenCalledTimes(1); + expect(cap.records().length).toBe(1); }); it('warns separately for distinct invalid values', () => { @@ -76,20 +77,20 @@ describe('getMaxFileSizeBytes', () => { getMaxFileSizeBytes(); process.env.GITNEXUS_MAX_FILE_SIZE = 'xyz'; getMaxFileSizeBytes(); - expect(warnSpy).toHaveBeenCalledTimes(2); + expect(cap.records().length).toBe(2); }); it('_resetMaxFileSizeWarnings re-enables warnings after reset', () => { process.env.GITNEXUS_MAX_FILE_SIZE = 'abc'; getMaxFileSizeBytes(); - expect(warnSpy).toHaveBeenCalledTimes(1); + expect(cap.records().length).toBe(1); getMaxFileSizeBytes(); - expect(warnSpy).toHaveBeenCalledTimes(1); + expect(cap.records().length).toBe(1); _resetMaxFileSizeWarnings(); getMaxFileSizeBytes(); - expect(warnSpy).toHaveBeenCalledTimes(2); + expect(cap.records().length).toBe(2); }); it('DEFAULT_MAX_FILE_SIZE_BYTES is 512 KB', () => { @@ -99,12 +100,12 @@ describe('getMaxFileSizeBytes', () => { describe('getMaxFileSizeBannerMessage', () => { const ORIGINAL = process.env.GITNEXUS_MAX_FILE_SIZE; - let warnSpy: ReturnType; + let cap: ReturnType; beforeEach(() => { delete process.env.GITNEXUS_MAX_FILE_SIZE; _resetMaxFileSizeWarnings(); - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + cap = _captureLogger(); }); afterEach(() => { @@ -113,7 +114,7 @@ describe('getMaxFileSizeBannerMessage', () => { } else { process.env.GITNEXUS_MAX_FILE_SIZE = ORIGINAL; } - warnSpy.mockRestore(); + cap.restore(); }); it('returns null when the env var is unset (default threshold)', () => { diff --git a/gitnexus/test/unit/mcp-stdout-sentinel.test.ts b/gitnexus/test/unit/mcp-stdout-sentinel.test.ts new file mode 100644 index 000000000..dfe83f471 --- /dev/null +++ b/gitnexus/test/unit/mcp-stdout-sentinel.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { withMcpWrite, isMcpWrite, createStdoutSentinel } from '../../src/mcp/stdio-context.js'; + +interface CapturedWrite { + target: 'stdout' | 'stderr'; + payload: string; +} + +function makeCapture() { + const captured: CapturedWrite[] = []; + const realStdoutWrite = (chunk: any) => { + captured.push({ + target: 'stdout', + payload: Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk), + }); + return true; + }; + const realStderrWrite = (chunk: any) => { + captured.push({ + target: 'stderr', + payload: Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk), + }); + return true; + }; + return { captured, realStdoutWrite, realStderrWrite }; +} + +function joinedStderr(captured: CapturedWrite[]): string { + return captured + .filter((c) => c.target === 'stderr') + .map((c) => c.payload) + .join(''); +} + +function joinedStdout(captured: CapturedWrite[]): string { + return captured + .filter((c) => c.target === 'stdout') + .map((c) => c.payload) + .join(''); +} + +describe('mcp/stdio-context — withMcpWrite / isMcpWrite', () => { + it('isMcpWrite returns false outside withMcpWrite', () => { + expect(isMcpWrite()).toBe(false); + }); + + it('isMcpWrite returns true inside withMcpWrite', () => { + let inside: boolean | undefined; + withMcpWrite(() => { + inside = isMcpWrite(); + }); + expect(inside).toBe(true); + }); + + it('isMcpWrite returns false again after withMcpWrite returns', () => { + withMcpWrite(() => { + // noop + }); + expect(isMcpWrite()).toBe(false); + }); + + it('withMcpWrite returns the inner value', () => { + const v = withMcpWrite(() => 42); + expect(v).toBe(42); + }); + + it('nested withMcpWrite stays tagged', () => { + let deep: boolean | undefined; + withMcpWrite(() => { + withMcpWrite(() => { + deep = isMcpWrite(); + }); + }); + expect(deep).toBe(true); + }); +}); + +describe('mcp/stdio-context — createStdoutSentinel', () => { + let capture: ReturnType; + + beforeEach(() => { + capture = makeCapture(); + }); + + it('passes writes through to real stdout when called inside withMcpWrite', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + withMcpWrite(() => { + sentinel.write('Content-Length: 42\r\n\r\n{"jsonrpc":"2.0"}'); + }); + expect(joinedStdout(capture.captured)).toBe('Content-Length: 42\r\n\r\n{"jsonrpc":"2.0"}'); + expect(joinedStderr(capture.captured)).toBe(''); + }); + + it('passes newline-terminated JSON-RPC frames through to stdout when tagged', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + withMcpWrite(() => { + sentinel.write('{"jsonrpc":"2.0","id":1}\n'); + }); + expect(joinedStdout(capture.captured)).toBe('{"jsonrpc":"2.0","id":1}\n'); + }); + + it('passes Buffer payloads through to stdout when tagged', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + withMcpWrite(() => { + sentinel.write(Buffer.from('payload', 'utf8')); + }); + expect(joinedStdout(capture.captured)).toBe('payload'); + }); + + it('redirects untagged writes to stderr with the [mcp:stdout-redirect] prefix', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + sentinel.write('rogue output\n'); + + const stderr = joinedStderr(capture.captured); + expect(stderr).toContain('[mcp:stdout-redirect]'); + expect(stderr).toContain('rogue output'); + expect(joinedStdout(capture.captured)).toBe(''); + }); + + it('emits a one-shot startup warning on the first redirect only', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + sentinel.write('first\n'); + sentinel.write('second\n'); + + const stderr = joinedStderr(capture.captured); + const warningMatches = stderr.match(/sentinel triggered/g) ?? []; + expect(warningMatches.length).toBe(1); + }); + + it('truncates redirect payload to maxBytes (default 200) and reports the overflow', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + const huge = 'x'.repeat(1024); + sentinel.write(huge); + + const stderr = joinedStderr(capture.captured); + // The redirected payload portion should not contain all 1024 x's. + expect(stderr.includes('x'.repeat(1024))).toBe(false); + expect(stderr).toContain('x'.repeat(200)); + expect(stderr).toMatch(/\(\+\d+ bytes truncated\)/); + }); + + it('respects a custom maxBytes', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + maxBytes: 8, + }); + sentinel.write('abcdefghijklmnop'); + + const stderr = joinedStderr(capture.captured); + expect(stderr).toContain('abcdefgh'); + expect(stderr.includes('abcdefghi')).toBe(false); + expect(stderr).toContain('truncated'); + }); + + it('rate-limits redirects to maxRedirects (default 10) — extras are suppressed', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + for (let i = 0; i < 15; i += 1) { + sentinel.write(`line-${i}\n`); + } + + const stderr = joinedStderr(capture.captured); + // First 10 lines are surfaced; lines 10-14 are suppressed. + for (let i = 0; i < 10; i += 1) { + expect(stderr).toContain(`line-${i}`); + } + for (let i = 10; i < 15; i += 1) { + expect(stderr.includes(`line-${i}`)).toBe(false); + } + expect(sentinel.stats().redirected).toBe(10); + expect(sentinel.stats().suppressed).toBe(5); + }); + + it('flushSummary emits the counter line when redirects occurred', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + sentinel.write('one\n'); + sentinel.write('two\n'); + sentinel.flushSummary(); + + const stderr = joinedStderr(capture.captured); + expect(stderr).toMatch(/summary:\s*2 redirected,\s*0 suppressed/); + }); + + it('flushSummary is silent when no redirects occurred', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + sentinel.flushSummary(); + + expect(joinedStderr(capture.captured)).toBe(''); + }); + + it('returns true for empty writes and never throws', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + expect(() => sentinel.write('')).not.toThrow(); + expect(() => sentinel.write(undefined as any)).not.toThrow(); + expect(sentinel.write('')).toBe(true); + }); + + it('handles plain Uint8Array (not Buffer) correctly when redirecting', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + // Plain Uint8Array — Buffer.isBuffer returns false. Bytes spell "hi\n". + const u8 = new Uint8Array([0x68, 0x69, 0x0a]); + sentinel.write(u8); + + const stderr = joinedStderr(capture.captured); + expect(stderr).toContain('hi'); + expect(stderr).not.toMatch(/\b104,\s*105/); // not falling through to String(chunk) → "104,105,10" + }); + + it('invokes the Writable callback (if provided) for redirected writes', async () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + let called = false; + let cbErr: Error | null | undefined = undefined; + sentinel.write('rogue\n', 'utf8', (err: Error | null | undefined) => { + called = true; + cbErr = err; + }); + // Callback fires on next tick, not sync. + expect(called).toBe(false); + await new Promise((r) => setImmediate(r)); + expect(called).toBe(true); + expect(cbErr).toBeNull(); + }); + + it('invokes the Writable callback for redirected writes when called past the rate-limit cap', async () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + maxRedirects: 1, + }); + sentinel.write('first\n'); + let called = false; + sentinel.write('second\n', () => { + called = true; + }); + await new Promise((r) => setImmediate(r)); + expect(called).toBe(true); + }); + + it('handles a multi-call sequence where some writes are tagged and some are not', () => { + const sentinel = createStdoutSentinel({ + realStdoutWrite: capture.realStdoutWrite, + realStderrWrite: capture.realStderrWrite, + }); + + withMcpWrite(() => sentinel.write('{"frame1":1}\n')); + sentinel.write('rogue-1\n'); + withMcpWrite(() => sentinel.write('{"frame2":2}\n')); + sentinel.write('rogue-2\n'); + + expect(joinedStdout(capture.captured)).toBe('{"frame1":1}\n{"frame2":2}\n'); + const stderr = joinedStderr(capture.captured); + expect(stderr).toContain('rogue-1'); + expect(stderr).toContain('rogue-2'); + }); +}); diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index 0087cfc87..03679045f 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -11,10 +11,10 @@ * per call, has the right signature, exposes the right error shape. * 2. Integration tests — mount the same factory on a tiny isolated express * app that does fs.readFile (the exact CodeQL sink class) and prove the - * 429 fires after the configured limit. Tight windowMs (100ms) + small - * sleep (200ms) keeps the suite fast and resistant to CI scheduling - * jitter; each test uses a fresh limiter so counter state never carries - * between tests. + * 429 fires after the configured limit. windowMs (2 000 ms) is generous + * enough that 4 sequential requests fit inside one window even on slow + * Windows CI runners; each test uses a fresh limiter so counter state + * never carries between tests. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import express, { type Express } from 'express'; @@ -41,9 +41,9 @@ afterAll(async () => { }); // Build a fresh app + server per test so counter state never carries between -// tests. Tight windowMs keeps the limiter responsive; the 200ms reset sleep -// in window-rollover tests gives 2x margin even on slow CI. -const buildApp = (limit: number, windowMs = 100): Express => { +// tests. windowMs = 2 000 ms gives ample headroom for Windows CI where +// sequential loopback HTTP requests can take 50–80 ms each. +const buildApp = (limit: number, windowMs = 2000): Express => { const app = express(); app.set('trust proxy', 'loopback, linklocal, uniquelocal'); app.get('/test/file', createRouteLimiter({ windowMs, limit }), async (_req, res) => { @@ -78,6 +78,16 @@ describe('createRouteLimiter — defaults', () => { // express middleware signature is (req, res, next) — 3 args. expect(limiter.length).toBe(3); }); + + // Regression guard for #1360 — createRouteLimiter must not throw + // ERR_ERL_KEY_GEN_IPV6. The validation fires at construction time + // (inside `rateLimit()`), so a simple `createRouteLimiter()` call is + // the canary: if the keyGenerator references `req.ip` without using + // `ipKeyGenerator`, the `rateLimit()` constructor throws before the + // middleware is ever invoked. + it('does not throw ERR_ERL_KEY_GEN_IPV6 on construction (#1360)', () => { + expect(() => createRouteLimiter()).not.toThrow(); + }); }); describe('createRouteLimiter — integration with a real route', () => { @@ -145,8 +155,8 @@ describe('createRouteLimiter — integration with a real route', () => { for (let i = 1; i <= 3; i++) await fetch(`${baseUrl}/test/file`); const tripped = await fetch(`${baseUrl}/test/file`); expect(tripped.status).toBe(429); - // Wait for the window to roll over (100ms window + 200ms margin). - await new Promise((r) => setTimeout(r, 200)); + // Wait for the window to roll over (2 000 ms window + 200 ms margin). + await new Promise((r) => setTimeout(r, 2200)); const reset = await fetch(`${baseUrl}/test/file`); expect(reset.status).toBe(200); }); @@ -242,9 +252,45 @@ describe('production routes — rate-limit middleware wiring', () => { expect(apiSource).toMatch(/app\.get\(SPA_FALLBACK_REGEX,\s*createRouteLimiter\(/); }); + it('GET /api/health is registered (Docker healthcheck, #1147)', () => { + expect(apiSource).toMatch(/app\.get\('\/api\/health',\s*\(_req,\s*res\)\s*=>/); + }); + it('createServer wires trust proxy to loopback/linklocal/uniquelocal', () => { expect(apiSource).toMatch( /app\.set\(\s*'trust proxy'\s*,\s*'loopback,\s*linklocal,\s*uniquelocal'\s*\)/, ); }); + + it('embed route flushes WAL via flushWAL, not inline executeQuery (#1376)', () => { + // The embed handler must call the consolidated helper, not hand-roll + // its own try/catch around executeQuery('CHECKPOINT'). + expect(apiSource).toMatch(/await flushWAL\(\)/); + expect(apiSource).not.toMatch(/executeQuery\('CHECKPOINT'\)/); + }); +}); + +// Structural guard for #1360 — validates that the validation module uses +// `ipKeyGenerator` so IPv6 addresses are normalised to their /56 subnet. +// Without this, each IPv6 address gets an independent counter and the +// rate-limit is trivially bypassed. The construction-time test above +// catches the same regression behaviourally; this source-grep test catches +// it structurally so the failure message is immediately obvious. +describe('validation.ts — IPv6 key normalisation (#1360)', () => { + let validationSource: string; + + beforeAll(async () => { + validationSource = await fs.readFile( + path.join(__dirname, '..', '..', 'src', 'server', 'validation.ts'), + 'utf-8', + ); + }); + + it('imports ipKeyGenerator from express-rate-limit', () => { + expect(validationSource).toMatch(/import.*ipKeyGenerator.*from\s+'express-rate-limit'/); + }); + + it('keyGenerator body calls ipKeyGenerator', () => { + expect(validationSource).toMatch(/ipKeyGenerator\(ip\)/); + }); }); diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index a688d82d0..4bbcacd12 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -2,7 +2,11 @@ import { execSync } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import { describe, it, expect } from 'vitest'; -import { deriveEmbeddingMode } from '../../src/core/embedding-mode.js'; +import { + deriveEmbeddingMode, + deriveEmbeddingCap, + DEFAULT_EMBEDDING_NODE_LIMIT, +} from '../../src/core/embedding-mode.js'; import { getStoragePaths, saveMeta, type RepoMeta } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; @@ -136,3 +140,39 @@ describe('deriveEmbeddingMode', () => { expect(m.preserveExistingEmbeddings).toBe(false); }); }); + +describe('deriveEmbeddingCap', () => { + it('uses the default 50K cap when limit is undefined', () => { + const d = deriveEmbeddingCap(10_000, undefined); + expect(d.nodeLimit).toBe(DEFAULT_EMBEDDING_NODE_LIMIT); + expect(d.capDisabled).toBe(false); + expect(d.skipForCap).toBe(false); + }); + + it('skips when node count exceeds the default cap', () => { + const d = deriveEmbeddingCap(75_000, undefined); + expect(d.skipForCap).toBe(true); + expect(d.capDisabled).toBe(false); + }); + + it('does not skip when node count equals the default cap (boundary)', () => { + const d = deriveEmbeddingCap(DEFAULT_EMBEDDING_NODE_LIMIT, undefined); + expect(d.skipForCap).toBe(false); + }); + + it('limit=0 disables the cap regardless of node count', () => { + const d = deriveEmbeddingCap(1_000_000, 0); + expect(d.capDisabled).toBe(true); + expect(d.skipForCap).toBe(false); + expect(d.nodeLimit).toBe(0); + }); + + it('honors a custom positive cap', () => { + expect(deriveEmbeddingCap(99_999, 100_000).skipForCap).toBe(false); + expect(deriveEmbeddingCap(100_001, 100_000).skipForCap).toBe(true); + }); + + it('custom cap below default still applies', () => { + expect(deriveEmbeddingCap(15_000, 10_000).skipForCap).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts b/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts new file mode 100644 index 000000000..edf48b33a --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { extractParsedFile } from '../../../../src/core/ingestion/scope-extractor-bridge.js'; +import { goScopeResolver } from '../../../../src/core/ingestion/languages/go/scope-resolver.js'; +import { populateGoRangeBindings } from '../../../../src/core/ingestion/languages/go/range-binding.js'; +import type { ParsedFile, ScopeResolutionIndexes } from 'gitnexus-shared'; + +function parseGo(src: string, path = 'main.go'): ParsedFile { + const p = extractParsedFile(goScopeResolver.languageProvider, src, path); + if (p === undefined) throw new Error(`scope extraction failed for ${path}`); + goScopeResolver.populateOwners(p); + return p; +} + +function makeEmptyIndexes(): ScopeResolutionIndexes { + return { + bindings: new Map(), + imports: [], + scopeTree: { roots: [] } as any, + methodDispatch: new Map(), + sccs: [], + } as ScopeResolutionIndexes; +} + +describe('Go range binding — null guard (#1346, #1366)', () => { + it('does not crash on plain for loop (no range_clause)', () => { + const src = `package main +func main() { + for i := 0; i < 10; i++ { + _ = i + } +}`; + const parsed = parseGo(src); + const fileContents = new Map([['main.go', src]]); + // Before fix: rangeClause was undefined, checked with === null, + // then rangeClause.namedChildren crashed. + expect(() => + populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }), + ).not.toThrow(); + }); + + it('does not crash on for-range without expression_list', () => { + // Single variable range without comma: for v := range ch + const src = `package main +func main() { + ch := make(chan int) + for v := range ch { + _ = v + } +}`; + const parsed = parseGo(src); + const fileContents = new Map([['main.go', src]]); + expect(() => + populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }), + ).not.toThrow(); + }); + + it('does not crash on for-range over map with blank identifier', () => { + const src = `package main +func main() { + m := map[string]int{"a": 1} + for _, v := range m { + _ = v + } +}`; + const parsed = parseGo(src); + const fileContents = new Map([['main.go', src]]); + expect(() => + populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }), + ).not.toThrow(); + }); + + it('does not crash on for-range with type alias target', () => { + const src = `package main +type Users []string +func main() { + var users Users + for _, u := range users { + _ = u + } +}`; + const parsed = parseGo(src); + const fileContents = new Map([['main.go', src]]); + expect(() => + populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }), + ).not.toThrow(); + }); + + it('does not crash on nested for loops mixing plain and range', () => { + const src = `package main +func main() { + items := []string{"a", "b"} + for i := 0; i < len(items); i++ { + for _, v := range items { + _ = v + } + } +}`; + const parsed = parseGo(src); + const fileContents = new Map([['main.go', src]]); + expect(() => + populateGoRangeBindings([parsed], makeEmptyIndexes(), { fileContents }), + ).not.toThrow(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts index 9ea213128..96da250fb 100644 --- a/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts +++ b/gitnexus/test/unit/scope-resolution/go/go-type-binding.test.ts @@ -112,3 +112,55 @@ describe('Go type binding synthesis — 7 patterns', () => { expect(normalizeGoTypeName('List[User]')).toBe('List'); }); }); + +describe('Go type binding — null guard (#1346, #1366)', () => { + it('does not crash on make(chan T) — channel_type has no matching child', () => { + const src = 'package main\nfunc main() {\n ch := make(chan int)\n}'; + const tree = getGoParser().parse(src); + // Before fix: .find() returned undefined, checked with !== null, crashed + // accessing .type on undefined. + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + // make(chan T) is not handled (V1 limitation) — should produce no crash + // and no make-binding, not crash the pipeline. + const makeMatch = matches.find((m) => m['@type-binding.make']); + expect(makeMatch).toBeUndefined(); + }); + + it('does not crash on new() with no type arguments', () => { + const src = 'package main\nfunc main() {\n x := new(complex128)\n _ = x\n}'; + const tree = getGoParser().parse(src); + // complex128 is a builtin type_identifier — this should work normally, + // but tests the path where .find() must not return undefined unchecked. + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + const newMatch = matches.find((m) => m['@type-binding.new']); + expect(newMatch).toBeDefined(); + expect(newMatch?.['@type-binding.type']?.text).toBe('complex128'); + }); + + it('does not crash on make with only generic type arguments', () => { + const src = 'package main\nfunc main() {\n ch := make(chan *User)\n}'; + const tree = getGoParser().parse(src); + // chan *User: sliceOrMap is undefined (channel_type not in ['slice_type','map_type']) + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + expect(matches.find((m) => m['@type-binding.make'])).toBeUndefined(); + }); + + it('does not crash on composite literal with embedded struct', () => { + const src = `package main +type Base struct{ ID int } +type User struct{ Base } +func main() { u := User{Base: Base{ID: 1}} }`; + const matches = emitGoScopeCaptures(src, 'main.go'); + // Should produce captures without crashing + expect(matches.length).toBeGreaterThan(0); + }); + + it('does not crash on short var decl with function call (no typeBinding)', () => { + const src = 'package main\nfunc main() {\n result := DoSomething()\n}'; + const tree = getGoParser().parse(src); + // RHS is a call_expression with no new/make — no typeBinding expected + const matches = synthesizeGoTypeBindings(tree.rootNode as any); + // Should not crash; may or may not produce bindings depending on the call + expect(Array.isArray(matches)).toBe(true); + }); +}); diff --git a/gitnexus/test/unit/sequential-language-availability.test.ts b/gitnexus/test/unit/sequential-language-availability.test.ts index f48db5778..bd0e4d4d6 100644 --- a/gitnexus/test/unit/sequential-language-availability.test.ts +++ b/gitnexus/test/unit/sequential-language-availability.test.ts @@ -19,6 +19,7 @@ import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.j import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js'; import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js'; +import { _captureLogger } from '../../src/core/logger.js'; describe('sequential native parser availability', () => { beforeEach(() => { vi.clearAllMocks(); @@ -43,7 +44,7 @@ describe('sequential native parser availability', () => { }); it('warns when processImports skips files in verbose mode', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); const previous = process.env.GITNEXUS_VERBOSE; process.env.GITNEXUS_VERBOSE = '1'; vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false); @@ -58,11 +59,17 @@ describe('sequential native parser availability', () => { ['App.swift'], ); - expect(warnSpy).toHaveBeenCalledWith( - '[ingestion] Skipped 1 swift file(s) in import processing — swift parser not available.', - ); + expect( + cap + .records() + .some( + (r) => + r.msg === + '[ingestion] Skipped 1 swift file(s) in import processing — swift parser not available.', + ), + ).toBe(true); - warnSpy.mockRestore(); + cap.restore(); if (previous === undefined) { delete process.env.GITNEXUS_VERBOSE; } else { @@ -86,7 +93,7 @@ describe('sequential native parser availability', () => { }); it('warns when processCalls skips files in verbose mode', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); const previous = process.env.GITNEXUS_VERBOSE; process.env.GITNEXUS_VERBOSE = '1'; vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false); @@ -98,11 +105,17 @@ describe('sequential native parser availability', () => { createResolutionContext(), ); - expect(warnSpy).toHaveBeenCalledWith( - '[ingestion] Skipped 1 swift file(s) in call processing — swift parser not available.', - ); + expect( + cap + .records() + .some( + (r) => + r.msg === + '[ingestion] Skipped 1 swift file(s) in call processing — swift parser not available.', + ), + ).toBe(true); - warnSpy.mockRestore(); + cap.restore(); if (previous === undefined) { delete process.env.GITNEXUS_VERBOSE; } else { @@ -126,7 +139,7 @@ describe('sequential native parser availability', () => { }); it('warns when processHeritage skips files in verbose mode', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); const previous = process.env.GITNEXUS_VERBOSE; process.env.GITNEXUS_VERBOSE = '1'; vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false); @@ -138,11 +151,17 @@ describe('sequential native parser availability', () => { createResolutionContext(), ); - expect(warnSpy).toHaveBeenCalledWith( - '[ingestion] Skipped 1 swift file(s) in heritage processing — swift parser not available.', - ); + expect( + cap + .records() + .some( + (r) => + r.msg === + '[ingestion] Skipped 1 swift file(s) in heritage processing — swift parser not available.', + ), + ).toBe(true); - warnSpy.mockRestore(); + cap.restore(); if (previous === undefined) { delete process.env.GITNEXUS_VERBOSE; } else { @@ -166,7 +185,7 @@ describe('sequential native parser availability', () => { }); it('warns when processParsing skips files in verbose mode', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); const previous = process.env.GITNEXUS_VERBOSE; process.env.GITNEXUS_VERBOSE = '1'; vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false); @@ -178,11 +197,17 @@ describe('sequential native parser availability', () => { createASTCache(), ); - expect(warnSpy).toHaveBeenCalledWith( - '[ingestion] Skipped 1 swift file(s) in parsing processing — swift parser not available.', - ); + expect( + cap + .records() + .some( + (r) => + r.msg === + '[ingestion] Skipped 1 swift file(s) in parsing processing — swift parser not available.', + ), + ).toBe(true); - warnSpy.mockRestore(); + cap.restore(); if (previous === undefined) { delete process.env.GITNEXUS_VERBOSE; } else { diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts index 499c1f638..95761bed0 100644 --- a/gitnexus/test/unit/setup-codex.test.ts +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -2,6 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; +import { createRequire } from 'module'; + +const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string }) + .version; +const NPX_REF = `gitnexus@${PKG_VERSION}`; const execFileMock = vi.fn((...args: any[]) => { const callback = args.at(-1); @@ -63,7 +68,7 @@ describe('setupCommand codex execution', () => { expect(execFileMock).toHaveBeenCalledWith( 'codex', - ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'], + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'], { shell: true }, expect.any(Function), ); @@ -78,7 +83,7 @@ describe('setupCommand codex execution', () => { expect(execFileMock).toHaveBeenCalledWith( 'codex', - ['mcp', 'add', 'gitnexus', '--', 'npx', '-y', 'gitnexus@latest', 'mcp'], + ['mcp', 'add', 'gitnexus', '--', 'npx', '-y', NPX_REF, 'mcp'], { shell: false }, expect.any(Function), ); diff --git a/gitnexus/test/unit/setup-jsonc.test.ts b/gitnexus/test/unit/setup-jsonc.test.ts index 3993b1218..e31955950 100644 --- a/gitnexus/test/unit/setup-jsonc.test.ts +++ b/gitnexus/test/unit/setup-jsonc.test.ts @@ -3,6 +3,11 @@ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { parse as parseJsonc } from 'jsonc-parser'; +import { createRequire } from 'module'; + +const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string }) + .version; +const NPX_REF = `gitnexus@${PKG_VERSION}`; const execFileMock = vi.fn((...args: any[]) => { const callback = args.at(-1); @@ -232,7 +237,7 @@ describe('setupOpenCode — JSONC preservation', () => { expect(config.mcp.gitnexus).toEqual({ type: 'local', - command: ['npx', '-y', 'gitnexus@latest', 'mcp'], + command: ['npx', '-y', NPX_REF, 'mcp'], }); }); diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 6cab31467..95ad261f0 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -2,6 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; +import { createRequire } from 'module'; + +// Match what setup.ts emits — read the version from the same package.json +// so the test never goes stale on a release bump. +const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string }) + .version; +const NPX_REF = `gitnexus@${PKG_VERSION}`; const execFileMock = vi.fn((...args: any[]) => { const callback = args.at(-1); @@ -75,7 +82,7 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toEqual({ command: 'cmd', - args: ['/c', 'npx', '-y', 'gitnexus@latest', 'mcp'], + args: ['/c', 'npx', '-y', NPX_REF, 'mcp'], }); }); @@ -90,7 +97,7 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toEqual({ command: 'npx', - args: ['-y', 'gitnexus@latest', 'mcp'], + args: ['-y', NPX_REF, 'mcp'], }); }); @@ -182,7 +189,7 @@ describe('setupClaudeCode', () => { expect(config.mcpServers.gitnexus).toEqual({ command: 'npx', - args: ['-y', 'gitnexus@latest', 'mcp'], + args: ['-y', NPX_REF, 'mcp'], }); }); diff --git a/gitnexus/test/unit/web-ui-serving.test.ts b/gitnexus/test/unit/web-ui-serving.test.ts index d3f085422..bd1c7faaf 100644 --- a/gitnexus/test/unit/web-ui-serving.test.ts +++ b/gitnexus/test/unit/web-ui-serving.test.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import http from 'node:http'; import express from 'express'; import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { _captureLogger } from '../../src/core/logger.js'; const { accessMock } = vi.hoisted(() => ({ accessMock: vi.fn(), @@ -213,7 +214,7 @@ describe('resolveWebDistDir', () => { }); it('warns on non-ENOENT errors but continues', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cap = _captureLogger(); accessMock.mockImplementation(async (p: string) => { if (p.includes('primary')) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); @@ -222,11 +223,16 @@ describe('resolveWebDistDir', () => { }); const result = await resolveWebDistDir('/primary', '/fallback'); expect(result).toBe('/fallback'); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('could not access web UI dir /primary'), - 'permission denied', - ); - warnSpy.mockRestore(); + expect( + cap + .records() + .some( + (r) => + String(r.msg ?? '').includes('could not access web UI dir /primary') && + r.err === 'permission denied', + ), + ).toBe(true); + cap.restore(); }); it('prefers GITNEXUS_WEB_DIST env var when set', async () => {