From cbe5dac8b7590efc11445032cd1ded6b64fe784e Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Tue, 5 May 2026 12:21:17 +0200 Subject: [PATCH 01/29] fix(test): widen rate-limit test window to prevent flake on Windows CI (#1347) --- gitnexus/test/unit/rate-limit.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index 0087cfc87..1802d57d0 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) => { @@ -145,8 +145,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); }); From 027340292fa0d40f96f4bd867a8571609ca08c9a Mon Sep 17 00:00:00 2001 From: GoGoLin <47466606+LINSUISHENG034@users.noreply.github.com> Date: Tue, 5 May 2026 20:35:22 +0800 Subject: [PATCH 02/29] fix(embeddings): add CHECKPOINT before closing database to prevent WAL corruption (#1314) --- gitnexus/src/core/lbug/lbug-adapter.ts | 31 ++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index bdacadff9..fb94753dd 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -257,6 +257,14 @@ 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 () => { + // CHECKPOINT before close to flush WAL contents (same rationale as closeLbug) + if (conn) { + try { + await conn.query('CHECKPOINT'); + } catch { + /* best-effort */ + } + } try { if (conn) await conn.close(); } catch { @@ -294,6 +302,14 @@ const ensureLbugInitialized = async (dbPath: string) => { const doInitLbug = async (dbPath: string) => { // Different database requested — close the old one first if (conn || db) { + // CHECKPOINT before close to flush WAL contents (same rationale as closeLbug) + if (conn) { + try { + await conn.query('CHECKPOINT'); + } catch { + /* ignore — older LadybugDB or schemaless DB may not accept it */ + } + } try { if (conn) await conn.close(); } catch {} @@ -1048,6 +1064,21 @@ export const fetchExistingEmbeddingHashes = async ( }; export const closeLbug = async (): Promise => { + // CHECKPOINT before close so the WAL/.shadow contents are flushed into + // the main database file. Without this, LadybugDB 0.16.0's non-blocking + // checkpoint thread can outlive the close call and leave sidecar pages + // pending on disk, which makes a subsequent read-side open either race + // with the WAL replay or trip the database-id check on the sidecars. + // This is especially critical after embedding writes, which generate + // large amounts of WAL data. CHECKPOINT is a no-op when there's nothing + // pending, so it's cheap on the happy path. + if (conn) { + try { + await conn.query('CHECKPOINT'); + } catch { + /* ignore — older LadybugDB or schemaless DB may not accept it */ + } + } if (conn) { try { await conn.close(); From 4048f53e359f76dcd0f51169a8d2a08125916c56 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Tue, 5 May 2026 14:29:42 +0100 Subject: [PATCH 03/29] fix(git): suppress stderr leak in getCurrentCommit and getGitRoot (#1172) (#1341) * fix(git): suppress stderr leak in getCurrentCommit and getGitRoot (#1172) Node's execSync forwards the child's stderr to the parent process when the stdio option is not explicitly set. getCurrentCommit and getGitRoot both caught the resulting error but did not suppress the stderr output, causing "fatal: not a git repository" messages to leak to the terminal whenever they were called on a path outside a git worktree. Add stdio: ['ignore', 'pipe', 'ignore'] to both functions, matching the pattern already used by getRemoteUrl, getRemoteOriginUrl, and getCanonicalRepoRoot in the same file. * address review: add getGitRoot stderr test, normalize em dashes to ASCII - Add matching process.stderr.write spy test for getGitRoot (#1172) - Replace U+2014 em dashes with ASCII -- in new comments --- gitnexus/src/storage/git.ts | 20 +++++++++++++-- gitnexus/test/unit/git-utils.test.ts | 37 +++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) 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/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 ───────────────────────────────────────────────────────── From 816ae5e66e00e551658e4ecb44211033af3bbda3 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Tue, 5 May 2026 15:40:39 +0200 Subject: [PATCH 04/29] fix(pool): wait for replacement worker online before dispatch (#1324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(test): widen worker pool retry timeout to prevent flake under load The "replaces a timed-out worker" test used 150ms idle timeout (600ms retry), which is too tight when CPU is contended during parallel test runs. Increase to 500ms (2s retry) — the test exercises the retry mechanism, not tight timing. Closes #1323 * fix(pool): wait for replacement worker to come online before dispatching Root cause: replaceWorker() spawned a new Worker but returned immediately without waiting for the thread to start. The subsequent runWorker() call started the idle timer and posted the sub-batch while the thread was still booting. Under CPU contention, thread startup latency consumed most of the retry timeout budget, causing the flake. Wait for the 'online' event before assigning the replacement worker. This ensures the idle timeout measures actual processing time, not thread startup overhead. Reverts the test timeout widening (500ms→150ms) since the root cause is now addressed. No production performance regression was found — the 30s default timeout is unaffected. Only the tight test timeouts were sensitive to startup latency. * fix(pool): harden replacement worker startup with three-event helper Address review feedback on the waitForWorkerOnline implementation: 1. Add waitForWorkerOnline helper that listens for 'online', 'error', and 'exit' events with proper cleanup after settlement. Prevents the dispatch promise from hanging if a replacement worker crashes before coming online (e.g. OOM, native addon failure). 2. Wrap replaceWorker call site in try/catch that routes failures through fail() — prevents unhandled promise rejections in the async setTimeout callback. 3. Re-check stopped flag after awaiting replacement startup — prevents injecting a live worker into a pool that was stopped by a concurrent failure during the await window. Terminates the orphaned replacement. 4. Add integration test for replacement worker crash during startup: worker throws on second load (marker-file gated), verifying the pool rejects the dispatch instead of hanging. * fix(pool): preserve original error in replacement worker catch The bare catch{} discarded the original error from waitForWorkerOnline, causing the startup-crash test regex to miss. Bind the error and include its message in the re-thrown Error. --- .../src/core/ingestion/workers/worker-pool.ts | 45 +++++++++++++++++-- gitnexus/test/integration/worker-pool.test.ts | 39 ++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/gitnexus/src/core/ingestion/workers/worker-pool.ts b/gitnexus/src/core/ingestion/workers/worker-pool.ts index 4be6af6b1..d06b2b38c 100644 --- a/gitnexus/src/core/ingestion/workers/worker-pool.ts +++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts @@ -94,6 +94,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 +234,21 @@ export const createWorkerPool = ( const replaceWorker = async (workerIndex: number) => { const worker = workers[workerIndex]; await worker?.terminate().catch(() => undefined); - if (!stopped) workers[workerIndex] = new Worker(workerUrl); + if (stopped) return; + const replacement = new Worker(workerUrl); + try { + await waitForWorkerOnline(replacement); + } catch (err) { + await replacement.terminate().catch(() => undefined); + throw new Error( + `Replacement worker ${workerIndex} failed to start: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (stopped) { + await replacement.terminate().catch(() => undefined); + return; + } + workers[workerIndex] = replacement; }; const fail = async (err: Error) => { @@ -341,9 +380,7 @@ export const createWorkerPool = ( try { await replaceWorker(workerIndex); } catch (err) { - void fail( - err instanceof Error ? err : new Error(`Worker replacement failed: ${err}`), - ); + void fail(err instanceof Error ? err : new Error(String(err))); return; } finally { activeWorkers--; diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts index eb0baebdf..4efe79363 100644 --- a/gitnexus/test/integration/worker-pool.test.ts +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -315,6 +315,45 @@ describe('worker pool integration', () => { } }); + it('rejects dispatch when replacement worker crashes during startup', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-replace-fail-')); + const markerPath = path.join(tempDir, 'first-attempt.txt'); + const workerPath = path.join(tempDir, 'worker.js'); + fs.writeFileSync( + workerPath, + ` + const fs = require('node:fs'); + const { parentPort } = require('node:worker_threads'); + const markerPath = ${JSON.stringify(markerPath)}; + if (fs.existsSync(markerPath)) { + throw new Error('simulated startup crash'); + } + parentPort.on('message', (msg) => { + if (msg && msg.type === 'sub-batch') { + fs.writeFileSync(markerPath, 'stalled'); + return; + } + }); + `, + ); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, { + subBatchIdleTimeoutMs: 150, + maxTimeoutRetries: 1, + timeoutBackoffFactor: 4, + }); + + try { + await expect(pool.dispatch([{ path: 'crash.ts', content: '' }])).rejects.toThrow( + /simulated startup crash|exited with code/, + ); + } finally { + warnSpy.mockRestore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('preserves global path order across split-and-retry', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-worker-split-')); const markerPath = path.join(tempDir, 'stalled-once.txt'); From e60e62f19304a7dbc6d95f187d45e0dc9a7c9779 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Tue, 5 May 2026 19:15:50 +0100 Subject: [PATCH 05/29] fix(test): widen worker pool retry timeout to prevent CI flake (#1323) (#1354) --- gitnexus/test/integration/worker-pool.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gitnexus/test/integration/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts index 4efe79363..b6e3a023c 100644 --- a/gitnexus/test/integration/worker-pool.test.ts +++ b/gitnexus/test/integration/worker-pool.test.ts @@ -300,7 +300,7 @@ describe('worker pool integration', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, { - subBatchIdleTimeoutMs: 150, + subBatchIdleTimeoutMs: 500, maxTimeoutRetries: 1, timeoutBackoffFactor: 4, }); @@ -308,7 +308,7 @@ 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')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Retrying with 2s timeout')); } finally { warnSpy.mockRestore(); fs.rmSync(tempDir, { recursive: true, force: true }); From 8fc32e6af9fd3e5c2dbb3dadc01361700d0980cf Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Tue, 5 May 2026 21:40:32 +0100 Subject: [PATCH 06/29] fix(docker): add dedicated health endpoint for container healthcheck (#1147) (#1355) --- docker-compose.yaml | 2 +- gitnexus/src/server/api.ts | 8 ++++++++ gitnexus/test/unit/rate-limit.test.ts | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) 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/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 376ab5876..69ffb6d44 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -183,6 +183,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

@@ -777,6 +778,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). diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index 1802d57d0..5a79e6085 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -242,6 +242,10 @@ 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*\)/, From 46e4c979c48bad9ff04636bf8a0e0c37e57b9475 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 6 May 2026 07:09:42 +0100 Subject: [PATCH 07/29] fix(server): flush WAL after /api/embed so search sees new embeddings (#1149) (#1359) --- gitnexus/src/server/api.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index 69ffb6d44..97309f811 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -1702,6 +1702,16 @@ 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. + try { + await executeQuery('CHECKPOINT'); + } catch { + /* best-effort -- older LadybugDB may not support it */ + } }); clearTimeout(embedTimeout); From 9d91530f9432e121159f7cc6cc51ac45b7033a9b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 07:41:46 +0100 Subject: [PATCH 08/29] chore(deps)(deps): bump express-rate-limit in /gitnexus (#1343) Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.4.1 to 8.5.0. - [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases) - [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.4.1...v8.5.0) --- updated-dependencies: - dependency-name: express-rate-limit dependency-version: 8.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 180c61fec..8a884f149 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3018,9 +3018,9 @@ } }, "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.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz", + "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", "license": "MIT", "dependencies": { "ip-address": "10.1.0" From e55256ba53f2f8183c6b2a60e9a559e4a77df3cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 07:52:58 +0100 Subject: [PATCH 09/29] chore(deps)(deps): bump axios (#1345) Bumps the npm_and_yarn group with 1 update in the /gitnexus-web directory: [axios](https://github.com/axios/axios). Updates `axios` from 1.15.0 to 1.16.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.16.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.16.0 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 10 +++++----- gitnexus-web/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index e5fccc412..5242600c9 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -16,7 +16,7 @@ "@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", @@ -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" } diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index d03509f4f..eab5d22f3 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -27,7 +27,7 @@ "@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", From 05ca80ea3039bae2661f4cfa2378e95cad1c2a5c Mon Sep 17 00:00:00 2001 From: 1PLee <1017270947@qq.com> Date: Wed, 6 May 2026 16:19:10 +0800 Subject: [PATCH 10/29] feat(ingestion): add thrift contracts impl (#1234) --- docs/guides/microservices-thrift.md | 185 +++++ gitnexus/src/core/group/config-parser.ts | 3 +- .../group/extractors/manifest-extractor.ts | 17 +- .../core/group/extractors/thrift-extractor.ts | 379 ++++++++++ .../group/extractors/thrift-patterns/index.ts | 16 + .../group/extractors/thrift-patterns/java.ts | 258 +++++++ .../group/extractors/thrift-patterns/types.ts | 20 + gitnexus/src/core/group/matching.ts | 135 ++-- gitnexus/src/core/group/sync.ts | 23 +- gitnexus/src/core/group/types.ts | 3 +- .../test/unit/group/config-parser.test.ts | 30 + .../unit/group/manifest-extractor.test.ts | 111 +++ gitnexus/test/unit/group/matching.test.ts | 197 ++++++ gitnexus/test/unit/group/sync.test.ts | 360 ++++++++++ .../test/unit/group/thrift-extractor.test.ts | 651 ++++++++++++++++++ gitnexus/test/unit/group/types.test.ts | 36 + 16 files changed, 2367 insertions(+), 57 deletions(-) create mode 100644 docs/guides/microservices-thrift.md create mode 100644 gitnexus/src/core/group/extractors/thrift-extractor.ts create mode 100644 gitnexus/src/core/group/extractors/thrift-patterns/index.ts create mode 100644 gitnexus/src/core/group/extractors/thrift-patterns/java.ts create mode 100644 gitnexus/src/core/group/extractors/thrift-patterns/types.ts create mode 100644 gitnexus/test/unit/group/thrift-extractor.test.ts 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/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/manifest-extractor.ts b/gitnexus/src/core/group/extractors/manifest-extractor.ts index 3185f05e1..0c3cd20ca 100644 --- a/gitnexus/src/core/group/extractors/manifest-extractor.ts +++ b/gitnexus/src/core/group/extractors/manifest-extractor.ts @@ -177,7 +177,7 @@ export class ManifestExtractor { // NOTE: All lookups use EXACT equality on the relevant name field and // deterministic ORDER BY before LIMIT 1. Previous versions used CONTAINS - // for fuzzy matching (plus an unconditional ".proto" fallback for gRPC) + // for fuzzy matching (plus an unconditional IDL file fallback for gRPC) // which produced silent false positives: e.g. manifest "/orders" would // match "/suborders", and a gRPC manifest entry in a repo with any // .proto file would attach to a random proto symbol. @@ -225,16 +225,21 @@ export class ManifestExtractor { LIMIT 1`, { contract: link.contract }, ); - } else if (link.type === 'grpc') { + } else if (link.type === 'grpc' || link.type === 'thrift') { // Contract is "Service/Method" or just "Service" (or package.Service // variants). Prefer matching by method name when present, otherwise - // by service name. NO .proto path fallback — that's guaranteed to - // return a wrong symbol in any repo with more than one proto file. + // by service name. Thrift generated Java classes often use + // package.Service in manifests while graph Class/Interface names are + // stored as bare Service, so strip the package prefix for thrift + // service-name lookups. NO IDL path fallback — that's guaranteed to + // return a wrong symbol in any repo with more than one IDL file. // Label filters scope lookups: methods → Function|Method, services // → Class|Interface (no label match = no silent wrong hits on // File/Variable nodes that happen to share the name). const parts = link.contract.split('/'); - const serviceName = parts[0]?.trim() ?? ''; + const rawServiceName = parts[0]?.trim() ?? ''; + const serviceName = + link.type === 'thrift' ? (rawServiceName.split('.').pop() ?? '') : rawServiceName; const methodName = parts[1]?.trim() ?? ''; if (methodName) { rows = await executor( @@ -344,6 +349,8 @@ export class ManifestExtractor { } case 'grpc': return `grpc::${contract}`; + case 'thrift': + return `thrift::${contract}`; case 'topic': return `topic::${contract}`; case 'lib': 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/sync.ts b/gitnexus/src/core/group/sync.ts index 09b289033..9a77df22a 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -6,10 +6,11 @@ import { readRegistry, type RegistryEntry } from '../../storage/repo-manager.js' import type { GroupConfig, RepoHandle, RepoSnapshot, StoredContract, CrossLink } from './types.js'; import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; import { GrpcExtractor } from './extractors/grpc-extractor.js'; +import { ThriftExtractor } from './extractors/thrift-extractor.js'; import { TopicExtractor } from './extractors/topic-extractor.js'; import { ManifestExtractor } from './extractors/manifest-extractor.js'; import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js'; -import { runExactMatch } from './matching.js'; +import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; import { writeContractRegistry } from './storage.js'; @@ -96,6 +97,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis const resolve = opts?.resolveRepoHandle ?? defaultResolveHandle(entries); const httpEx = new HttpRouteExtractor(); const grpcEx = new GrpcExtractor(); + const thriftEx = new ThriftExtractor(); const topicEx = new TopicExtractor(); dbExecutors = new Map(); const openPoolIds: string[] = []; @@ -143,6 +145,17 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } + if (config.detect.thrift) { + const extracted = await thriftEx.extract(executor, handle.repoPath, handle); + for (const c of extracted) { + autoContracts.push({ + ...c, + repo: groupPath, + service: assignService(c.symbolRef.filePath, boundaries), + }); + } + } + if (config.detect.topics) { const extracted = await topicEx.extract(executor, handle.repoPath, handle); for (const c of extracted) { @@ -234,13 +247,15 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis } } - const { matched, unmatched } = runExactMatch(autoContracts, undefined, config.matching); + const providerIndex = buildProviderIndex(autoContracts, config.matching); + const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching); + const wildcard = runWildcardMatch(unmatched, providerIndex); // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a // manifest-declared link can also emit a matchType:'exact' CrossLink with the // same endpoints. Prefer the manifest version — it reflects operator intent // and carries matchType:'manifest' which downstream consumers may rely on. - const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched]); + const crossLinks = dedupeCrossLinks([...manifestCrossLinks, ...matched, ...wildcard.matched]); const allContracts: StoredContract[] = autoContracts; const registry: ContractRegistry = { @@ -259,7 +274,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis return { contracts: allContracts, crossLinks, - unmatched, + unmatched: wildcard.remaining, missingRepos, repoSnapshots, }; 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/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/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/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index a6b6376c0..17bd1c45d 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -22,6 +22,7 @@ describe('syncGroup', () => { detect: { http: true, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, @@ -229,9 +230,11 @@ describe('syncGroup', () => { detect: { http: true, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, + workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, }; @@ -264,6 +267,359 @@ describe('syncGroup', () => { expect(result.crossLinks).toHaveLength(1); }); + it('runs thrift wildcard matching after exact matching and returns wildcard remaining', async () => { + const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }); + const provider: StoredContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.9, + meta: {}, + repo: 'app/provider', + }; + const consumer: StoredContract = { + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-order-service', + symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' }, + symbolName: 'OrderClient', + confidence: 0.8, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [provider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('wildcard'); + expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/*'); + expect(result.crossLinks[0].from.repo).toBe('app/consumer'); + expect(result.crossLinks[0].to.repo).toBe('app/provider'); + expect(result.unmatched).toEqual([provider]); + }); + + it('keeps wildcard thrift links to multiple extracted IDL provider methods', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-wildcard-')); + fs.mkdirSync(path.join(tmpDir, 'idl'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) + OrderResponse GetOrder(1: string orderId) +}`, + ); + + try { + const { ThriftExtractor } = + await import('../../../src/core/group/extractors/thrift-extractor.js'); + const extractedProviders = ( + await new ThriftExtractor().extract(null, tmpDir, { + id: 'provider-repo', + path: 'app/provider', + repoPath: tmpDir, + storagePath: path.join(tmpDir, '.gitnexus'), + }) + ) + .filter((c) => c.role === 'provider') + .map( + (c): StoredContract => ({ + ...c, + repo: 'app/provider', + }), + ); + + const consumer: StoredContract = { + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'manifest::app/consumer::thrift::OrderService/*', + symbolRef: { filePath: 'group.yaml', name: 'OrderService' }, + symbolName: 'OrderService', + confidence: 1, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(makeConfig({}), { + extractorOverride: async () => [...extractedProviders, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(2); + expect(result.crossLinks.map((cl) => cl.to.symbolRef.name).sort()).toEqual([ + 'OrderService.GetOrder', + 'OrderService.PlaceOrder', + ]); + expect(new Set(result.crossLinks.map((cl) => cl.to.symbolUid)).size).toBe(2); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('matches weak thrift method consumers to namespace-qualified providers during sync', async () => { + const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }); + const provider: StoredContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'idl/order.thrift', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.85, + meta: {}, + repo: 'app/provider', + }; + const consumer: StoredContract = { + contractId: 'thrift::OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-place-order', + symbolRef: { filePath: 'src/BillingWorkflow.java', name: 'orderService.PlaceOrder' }, + symbolName: 'orderService.PlaceOrder', + confidence: 0.45, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [provider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('exact'); + expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/PlaceOrder'); + expect(result.crossLinks[0].from.repo).toBe('app/consumer'); + expect(result.crossLinks[0].to.repo).toBe('app/provider'); + expect(result.unmatched).toHaveLength(0); + }); + + it('keeps exact thrift links to extracted IDL and Java providers for same method', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-exact-')); + fs.mkdirSync(path.join(tmpDir, 'idl'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'src', 'main', 'java', 'example'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + fs.writeFileSync( + path.join(tmpDir, 'src', 'main', 'java', 'example', 'IfaceOrderHandler.java'), + `package example; + +class IfaceOrderHandler implements OrderService.Iface { + public PlaceOrderResponse PlaceOrder(PlaceOrderRequest request) { + return new PlaceOrderResponse(); + } +}`, + ); + + try { + const { ThriftExtractor } = + await import('../../../src/core/group/extractors/thrift-extractor.js'); + const extractedProviders = ( + await new ThriftExtractor().extract(null, tmpDir, { + id: 'provider-repo', + path: 'app/provider', + repoPath: tmpDir, + storagePath: path.join(tmpDir, '.gitnexus'), + }) + ) + .filter((c) => c.role === 'provider') + .map( + (c): StoredContract => ({ + ...c, + repo: 'app/provider', + }), + ); + + const consumer: StoredContract = { + contractId: 'thrift::OrderService/PlaceOrder', + type: 'thrift', + role: 'consumer', + symbolUid: [ + 'source-scan::thrift', + 'consumer', + 'OrderService/PlaceOrder', + 'src/BillingWorkflow.java', + 'orderService.PlaceOrder', + ].join('::'), + symbolRef: { filePath: 'src/BillingWorkflow.java', name: 'orderService.PlaceOrder' }, + symbolName: 'orderService.PlaceOrder', + confidence: 0.45, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(makeConfig({}), { + extractorOverride: async () => [...extractedProviders, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(2); + expect(result.crossLinks.map((cl) => cl.to.symbolRef.filePath).sort()).toEqual([ + 'idl/order.thrift', + 'src/main/java/example/IfaceOrderHandler.java', + ]); + expect(new Set(result.crossLinks.map((cl) => cl.to.symbolUid)).size).toBe(2); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('extracts thrift contracts during real sync when thrift detection is enabled', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-')); + const storageDir = path.join(tmpDir, '.gitnexus'); + fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'idl'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'src'), { recursive: true }); + fs.mkdirSync(storageDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'services', 'billing', 'package.json'), '{}'); + fs.writeFileSync( + path.join(tmpDir, 'services', 'billing', 'src', 'BillingWorkflow.java'), + 'package example; class BillingWorkflow {}', + ); + fs.writeFileSync( + path.join(tmpDir, 'services', 'billing', 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + const config = makeConfig({ 'services/billing': 'billing-repo' }); + config.detect.http = false; + config.detect.thrift = true; + + const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js'); + const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined); + const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined); + + try { + const result = await syncGroup(config, { + resolveRepoHandle: async (_name, groupPath) => ({ + id: 'billing-repo', + path: groupPath, + repoPath: tmpDir, + storagePath: storageDir, + }), + skipWrite: true, + }); + + expect(result.missingRepos).toHaveLength(0); + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0]).toMatchObject({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + repo: 'services/billing', + service: 'services/billing', + symbolRef: { + filePath: 'services/billing/idl/order.thrift', + name: 'OrderService.PlaceOrder', + }, + }); + expect(initSpy).toHaveBeenCalledWith('billing-repo', path.join(storageDir, 'lbug')); + expect(closeSpy).toHaveBeenCalledWith('billing-repo'); + } finally { + initSpy.mockRestore(); + closeSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('does not extract thrift contracts during real sync when thrift detection is disabled', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-thrift-off-')); + const storageDir = path.join(tmpDir, '.gitnexus'); + fs.mkdirSync(path.join(tmpDir, 'services', 'billing', 'idl'), { recursive: true }); + fs.mkdirSync(storageDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, 'services', 'billing', 'idl', 'order.thrift'), + `namespace java billing.v1 + +service OrderService { + PlaceOrderResponse PlaceOrder(1: PlaceOrderRequest request) +}`, + ); + + const config = makeConfig({ 'services/billing': 'billing-repo' }); + config.detect.http = false; + config.detect.thrift = false; + + const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js'); + const initSpy = vi.spyOn(poolAdapter, 'initLbug').mockResolvedValue(undefined); + const closeSpy = vi.spyOn(poolAdapter, 'closeLbug').mockResolvedValue(undefined); + + try { + const result = await syncGroup(config, { + resolveRepoHandle: async (_name, groupPath) => ({ + id: 'billing-repo', + path: groupPath, + repoPath: tmpDir, + storagePath: storageDir, + }), + skipWrite: true, + }); + + expect(result.missingRepos).toHaveLength(0); + expect(result.contracts).toHaveLength(0); + } finally { + initSpy.mockRestore(); + closeSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('dedupes duplicate wildcard cross-links during sync', async () => { + const config = makeConfig({ 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }); + const provider: StoredContract = { + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.9, + meta: {}, + repo: 'app/provider', + }; + const duplicateProvider: StoredContract = { + ...provider, + confidence: 0.7, + }; + const consumer: StoredContract = { + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-order-service', + symbolRef: { filePath: 'src/consumer.ts', name: 'OrderClient' }, + symbolName: 'OrderClient', + confidence: 0.8, + meta: {}, + repo: 'app/consumer', + }; + + const result = await syncGroup(config, { + extractorOverride: async () => [provider, duplicateProvider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('wildcard'); + }); + it('manifest links referencing unknown repos still produce cross-links via synthetic UIDs', async () => { const links: GroupManifestLink[] = [ { @@ -285,9 +641,11 @@ describe('syncGroup', () => { detect: { http: true, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, + workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, }; @@ -350,6 +708,7 @@ describe('syncGroup', () => { detect: { http: false, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, @@ -506,6 +865,7 @@ describe('syncGroup', () => { detect: { http: false, grpc: false, + thrift: false, topics: false, shared_libs: false, embedding_fallback: false, 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: { From a418c47e29f0209492cff0589fe7b9e54eff5f24 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 6 May 2026 14:07:37 +0100 Subject: [PATCH 11/29] fix(server): use ipKeyGenerator for IPv6 subnet normalisation (#1360) (#1374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom keyGenerator in createRouteLimiter referenced req.ip without passing it through express-rate-limit's ipKeyGenerator helper. This caused ERR_ERL_KEY_GEN_IPV6 on startup when binding to 0.0.0.0, and meant each full IPv6 address got its own rate-limit counter — trivially bypassing the per-IP limit. Wrap the IP through ipKeyGenerator so IPv6 addresses are collapsed to their /56 subnet before keying the counter. The existing fallback chain (req.ip → socket.remoteAddress → 'unknown') is preserved to keep ERR_ERL_UNDEFINED_IP_ADDRESS from firing on abruptly closed connections. Tests: 3 new assertions (construction-time regression guard, source-grep for import and call site). --- gitnexus/src/server/validation.ts | 10 ++++++-- gitnexus/test/unit/rate-limit.test.ts | 35 +++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/gitnexus/src/server/validation.ts b/gitnexus/src/server/validation.ts index 54bf73d4f..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, { type RateLimitRequestHandler } from 'express-rate-limit'; +import rateLimit, { type RateLimitRequestHandler, ipKeyGenerator } from 'express-rate-limit'; import type { Request } from 'express'; /** @@ -138,6 +138,9 @@ export interface RouteLimiterOverrides { * - keyGenerator: req.ip with a socket.remoteAddress fallback so abruptly * closed connections do not trigger ERR_ERL_UNDEFINED_IP_ADDRESS * (which would 500 the request via Express's default error handler). + * The IP is passed through `ipKeyGenerator` so IPv6 addresses are + * normalised to their /56 subnet — without this, each IPv6 address + * gets its own counter and the limit is trivially bypassed (#1360). * Caller must wire `app.set('trust proxy', ...)` correctly — see * createServer in api.ts. * @@ -151,7 +154,10 @@ export function createRouteLimiter(opts?: RouteLimiterOverrides): RateLimitReque standardHeaders: 'draft-7', legacyHeaders: false, passOnStoreError: true, - keyGenerator: (req: Request) => req.ip ?? req.socket?.remoteAddress ?? 'unknown', + keyGenerator: (req: Request) => { + const ip = req.ip ?? req.socket?.remoteAddress; + return ip ? ipKeyGenerator(ip) : 'unknown'; + }, message: { error: 'Too many requests, please try again later.' }, ...opts, }); diff --git a/gitnexus/test/unit/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index 5a79e6085..b939c271e 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -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', () => { @@ -252,3 +262,28 @@ describe('production routes — rate-limit middleware wiring', () => { ); }); }); + +// 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\)/); + }); +}); From 96578aa4a899cba9f7bbc17f249f46e10572e0d9 Mon Sep 17 00:00:00 2001 From: Jorrin <43169049+JorrinKievit@users.noreply.github.com> Date: Wed, 6 May 2026 17:31:28 +0200 Subject: [PATCH 12/29] feat: add optional limit arg to --embeddings flag (closes #382) (#1375) --- gitnexus/src/cli/analyze.ts | 31 +++++- gitnexus/src/cli/index.ts | 6 +- gitnexus/src/core/embedding-mode.ts | 32 ++++++ gitnexus/src/core/run-analyze.ts | 39 +++++-- .../unit/analyze-embeddings-limit.test.ts | 101 ++++++++++++++++++ gitnexus/test/unit/run-analyze.test.ts | 42 +++++++- 6 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 gitnexus/test/unit/analyze-embeddings-limit.test.ts diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 2c100228d..2c199ae66 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -95,7 +95,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 @@ -167,6 +174,25 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption ); } + // Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap + // (0 disables the cap entirely). Validated up here so failures match the + // sibling-validation pattern (exit before bar.start() — otherwise + // process.exit() leaves the progress bar's hidden cursor uncleared). + let embeddingsNodeLimit: number | undefined; + if (typeof options?.embeddings === 'string') { + const parsed = Number(options.embeddings); + if (!Number.isInteger(parsed) || parsed < 0) { + console.error( + ` --embeddings expects a non-negative integer (got "${options.embeddings}"). ` + + `Pass 0 to disable the safety cap, or omit the value to keep the default.\n`, + ); + process.exitCode = 1; + return; + } + embeddingsNodeLimit = parsed; + } + const embeddingsEnabled = !!options?.embeddings; + const setPositiveEnv = ( optionName: string, envName: string, @@ -338,7 +364,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, 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/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/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/test/unit/analyze-embeddings-limit.test.ts b/gitnexus/test/unit/analyze-embeddings-limit.test.ts new file mode 100644 index 000000000..93978ad9d --- /dev/null +++ b/gitnexus/test/unit/analyze-embeddings-limit.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const runFullAnalysisMock = vi.fn(); + +vi.mock('../../src/core/run-analyze.js', () => ({ + runFullAnalysis: runFullAnalysisMock, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + closeLbug: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), + getGlobalRegistryPath: vi.fn(() => 'registry.json'), + RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, + AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, + assertAnalysisFinalized: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(() => '/repo'), + hasGitDir: vi.fn(() => true), +})); + +vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ + getMaxFileSizeBannerMessage: vi.fn(() => null), +})); + +describe('analyzeCommand --embeddings [limit] parsing', () => { + beforeEach(() => { + vi.resetModules(); + runFullAnalysisMock.mockReset(); + runFullAnalysisMock.mockResolvedValue({ + repoName: 'repo', + repoPath: '/repo', + stats: {}, + alreadyUpToDate: true, + }); + process.exitCode = undefined; + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); + }); + + it.each(['abc', '-1', '1.5', 'NaN', 'Infinity'])( + 'rejects invalid --embeddings value %s before analysis starts', + async (embeddings) => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings }); + + expect(process.exitCode).toBe(1); + expect(runFullAnalysisMock).not.toHaveBeenCalled(); + const msg = errorSpy.mock.calls[0]?.[0] ?? ''; + expect(msg).toContain('--embeddings expects a non-negative integer'); + expect(msg).toContain(`got "${embeddings}"`); + errorSpy.mockRestore(); + }, + ); + + it('bare --embeddings forwards undefined limit (default cap honored downstream)', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: true }); + + expect(runFullAnalysisMock).toHaveBeenCalledTimes(1); + const opts = runFullAnalysisMock.mock.calls[0][1]; + expect(opts.embeddings).toBe(true); + expect(opts.embeddingsNodeLimit).toBeUndefined(); + }); + + it('--embeddings 0 forwards 0 (cap disabled downstream)', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { embeddings: '0' }); + + const opts = runFullAnalysisMock.mock.calls[0][1]; + expect(opts.embeddings).toBe(true); + expect(opts.embeddingsNodeLimit).toBe(0); + }); + + it('--embeddings 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/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); + }); +}); From 28df98c99753cfba29b778956171f6f39124df45 Mon Sep 17 00:00:00 2001 From: evolution Date: Thu, 7 May 2026 00:57:19 +0800 Subject: [PATCH 13/29] fix(go): use loose equality for Array.find() null checks (#1384) * fix(go): use loose equality for Array.find() null checks (#1346, #1366) Array.find() returns undefined (not null) when no match is found, but the code checked with === null / !== null which fails to intercept it. This caused "Cannot read properties of undefined (reading 'type')" and "Cannot read properties of undefined (reading 'namedChildren')" crashes on Go files containing plain for loops, make(chan T), or other patterns where the expected tree-sitter node type is absent. * refactor(go): use strict undefined checks for Array.find() results Address review feedback: Array.find() returns undefined by spec, so check with === undefined / !== undefined instead of loose == null. --- .../ingestion/languages/go/range-binding.ts | 4 +- .../ingestion/languages/go/type-binding.ts | 6 +- .../go/go-range-binding-null-guard.test.ts | 104 ++++++++++++++++++ .../go/go-type-binding.test.ts | 52 +++++++++ 4 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 gitnexus/test/unit/scope-resolution/go/go-range-binding-null-guard.test.ts 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/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); + }); +}); From b486d04d75a633a531f1044b0f2070f243d50073 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 6 May 2026 19:18:04 +0100 Subject: [PATCH 14/29] refactor(lbug): extract safeClose helper to consolidate WAL flush (#1377) --- eslint.config.mjs | 21 +++++ gitnexus/src/core/lbug/lbug-adapter.ts | 100 ++++++++++----------- gitnexus/src/server/api.ts | 9 +- gitnexus/test/helpers/test-indexed-db.ts | 6 ++ gitnexus/test/unit/lbug-checkpoint.test.ts | 88 ++++++++++++++++++ gitnexus/test/unit/rate-limit.test.ts | 7 ++ 6 files changed, 172 insertions(+), 59 deletions(-) create mode 100644 gitnexus/test/unit/lbug-checkpoint.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 5b365ba67..2eb27dbae 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -79,6 +79,27 @@ export default [ }, }, + // Prevent direct conn.close() / db.close() in the LadybugDB adapter (#1376). + // All close operations must go through safeClose() so the WAL is always + // flushed before the connection is released. The sole authorised call site + // inside safeClose itself uses an eslint-disable-next-line override. + { + files: ['gitnexus/src/core/lbug/lbug-adapter.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.object.name='conn'][callee.property.name='close']", + message: 'Use safeClose() instead of calling conn.close() directly (#1376).', + }, + { + selector: "CallExpression[callee.object.name='db'][callee.property.name='close']", + message: 'Use safeClose() instead of calling db.close() directly (#1376).', + }, + ], + }, + }, + // Disable formatting rules (prettier handles those) prettierConfig, ]; diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index fb94753dd..ba83d559e 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -257,26 +257,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 () => { - // CHECKPOINT before close to flush WAL contents (same rationale as closeLbug) - if (conn) { - try { - await conn.query('CHECKPOINT'); - } catch { - /* best-effort */ - } - } - 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; @@ -302,22 +283,7 @@ const ensureLbugInitialized = async (dbPath: string) => { const doInitLbug = async (dbPath: string) => { // Different database requested — close the old one first if (conn || db) { - // CHECKPOINT before close to flush WAL contents (same rationale as closeLbug) - if (conn) { - try { - await conn.query('CHECKPOINT'); - } catch { - /* ignore — older LadybugDB or schemaless DB may not accept it */ - } - } - 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; @@ -1063,34 +1029,62 @@ export const fetchExistingEmbeddingHashes = async ( } }; -export const closeLbug = async (): Promise => { - // CHECKPOINT before close so the WAL/.shadow contents are flushed into - // the main database file. Without this, LadybugDB 0.16.0's non-blocking - // checkpoint thread can outlive the close call and leave sidecar pages - // pending on disk, which makes a subsequent read-side open either race - // with the WAL replay or trip the database-id check on the sidecars. - // This is especially critical after embedding writes, which generate - // large amounts of WAL data. CHECKPOINT is a no-op when there's nothing - // pending, so it's cheap on the happy path. - if (conn) { - try { - await conn.query('CHECKPOINT'); - } catch { - /* ignore — older LadybugDB or schemaless DB may not accept it */ - } +/** + * 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/server/api.ts b/gitnexus/src/server/api.ts index 97309f811..a8f784630 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'; @@ -1706,12 +1707,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // 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. - try { - await executeQuery('CHECKPOINT'); - } catch { - /* best-effort -- older LadybugDB may not support it */ - } + // connection open for other routes — a CHECKPOINT is enough. + await flushWAL(); }); clearTimeout(embedTimeout); 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/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/rate-limit.test.ts b/gitnexus/test/unit/rate-limit.test.ts index b939c271e..03679045f 100644 --- a/gitnexus/test/unit/rate-limit.test.ts +++ b/gitnexus/test/unit/rate-limit.test.ts @@ -261,6 +261,13 @@ describe('production routes — rate-limit middleware wiring', () => { /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 From 608be7655d577a911e20b7c51ff93c7f3c8eab96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 22:35:27 +0100 Subject: [PATCH 15/29] chore(deps)(deps): bump @langchain/core in /gitnexus-web (#1394) --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 5242600c9..fdb5471bd 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0", "dependencies": { "@langchain/anthropic": "^1.3.27", - "@langchain/core": "^1.1.41", + "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.28", "@langchain/langgraph": "^1.2.9", "@langchain/ollama": "^1.2.6", @@ -1412,9 +1412,9 @@ } }, "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", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index eab5d22f3..7e004472b 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -20,7 +20,7 @@ "dependencies": { "gitnexus-shared": "file:../gitnexus-shared", "@langchain/anthropic": "^1.3.27", - "@langchain/core": "^1.1.41", + "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.28", "@langchain/langgraph": "^1.2.9", "@langchain/ollama": "^1.2.6", From bc98239fb45a0061d65753e3aeb8802a00d65c36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 22:36:56 +0100 Subject: [PATCH 16/29] chore(deps)(deps): bump express-rate-limit in /gitnexus (#1397) --- gitnexus/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 8a884f149..ccc61bc5b 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -3018,12 +3018,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz", - "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -3486,9 +3486,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" From 84564e09b8a0d70d339b70c7075d392b480ac891 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 22:37:46 +0100 Subject: [PATCH 17/29] chore(deps)(deps): bump react-dom from 19.2.5 to 19.2.6 in /gitnexus-web (#1396) --- gitnexus-web/package-lock.json | 16 ++++++++-------- gitnexus-web/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index fdb5471bd..3489d6bd9 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -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", @@ -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 7e004472b..01f4aa807 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -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", From 68e4a5aeced9aa4006e2cc57410bd3b693f4ce73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 22:38:07 +0100 Subject: [PATCH 18/29] chore(deps)(deps-dev): bump jsdom from 29.0.2 to 29.1.1 in /gitnexus-web (#1395) --- gitnexus-web/package-lock.json | 54 +++++++++++++++++----------------- gitnexus-web/package.json | 2 +- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 3489d6bd9..7a2d92243 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -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": [ { @@ -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" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 01f4aa807..aed71ca9b 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -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", From 7639308f6588803e041864a79d14b812805b822e Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Thu, 7 May 2026 07:00:49 +0100 Subject: [PATCH 19/29] fix(security): replace predictable tempfile names with crypto.randomBytes (#1387) --- gitnexus/src/core/group/bridge-db.ts | 39 +++- gitnexus/src/core/group/storage.ts | 3 +- .../test/unit/group/insecure-tempfile.test.ts | 192 ++++++++++++++++++ 3 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 gitnexus/test/unit/group/insecure-tempfile.test.ts diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 76eb5c0f7..ef45d6819 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'; @@ -24,7 +24,7 @@ import { dedupeContracts, dedupeCrossLinks } from './normalization.js'; * - `.shadow` — non-blocking concurrent checkpoint sidecar (added in * LadybugDB 0.15.4); same pairing constraint as `.wal`. * - * `bridge-db` writes to a `bridge.lbug.tmp` file and then atomically renames + * `bridge-db` writes to a `bridge.lbug.tmp.` file and then atomically renames * it into place. The rename only moves the main file; sidecars must be * cleaned up explicitly or the next writer trips the database-id check. */ @@ -41,6 +41,26 @@ async function removeLbugFile(basePath: string): Promise { } } +/** + * 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 +296,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 +366,7 @@ export async function writeBridge( const crossLinks = dedupeCrossLinks(input.crossLinks); const finalPath = path.join(groupDir, 'bridge.lbug'); - const tmpPath = path.join(groupDir, 'bridge.lbug.tmp'); + const tmpPath = path.join(groupDir, `bridge.lbug.tmp.${randomBytes(8).toString('hex')}`); const bakPath = path.join(groupDir, 'bridge.lbug.bak'); const report: WriteBridgeReport = { @@ -366,11 +386,12 @@ export async function writeBridge( } }; - // Clean up any leftover tmp main file AND its `.wal` / `.shadow` sidecars. - // LadybugDB 0.16.0 rejects opening a database whose sidecars belong to a - // different database instance (database-id check), so any stale sidecar - // from a crashed previous run will fail the next writeBridge. - await removeLbugFile(tmpPath); + // Clean up stale tmp files left behind by previously crashed writeBridge + // runs. With randomBytes-based names each run picks a unique path, so + // the old fixed-name `removeLbugFile(tmpPath)` was a no-op — stale + // artifacts accumulated. The glob-based helper finds *all* leftover + // `bridge.lbug.tmp.*` entries and removes them (including sidecars). + await cleanStaleBridgeTmpFiles(groupDir); // 1. Create temp DB, insert all data. // 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/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); + }); +}); From de63418f7e620d8e0d297b43314ad46555e18c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 7 May 2026 09:14:33 +0100 Subject: [PATCH 20/29] =?UTF-8?q?fix(mcp):=20close=20MCP=20server=20timeou?= =?UTF-8?q?t=20=E2=80=94=20stdout=20discipline=20+=20cold-start=20friction?= =?UTF-8?q?=20(#1383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(lbug): route diagnostic logs to stderr to avoid MCP stdio corruption Replace console.log/console.warn with console.error in core/lbug so diagnostic messages reach stderr and never corrupt the JSON-RPC stream on MCP stdio. Per spec, the server MUST NOT write anything to stdout that is not a valid MCP message. - lbug-adapter.ts:367 - schema creation warning (MCP-reachable via lazy DB init from tool handlers) - lbug-adapter.ts:1047,1054 - legacy embedding fallback diagnostics (currently HTTP-only, but covered by upcoming no-console lint rule) - extension-loader.ts:191 - default warn handler fallback used during DuckDB extension loading * feat(mcp): add stdout sentinel via AsyncLocalStorage transport-write tagging Untagged process.stdout.write calls now redirect to stderr with a [mcp:stdout-redirect] prefix instead of corrupting the JSON-RPC frame stream. Identification is correctness-by-construction: the transport wraps every send() in withMcpWrite() (AsyncLocalStorage) and the sentinel checks isMcpWrite() per call. A byte-shape heuristic would have falsely rejected Content-Length frames (start with C, end with }) and misclassified multi-chunk writes. - gitnexus/src/mcp/stdio-context.ts: AsyncLocalStorage helpers + factory - gitnexus/src/mcp/server.ts: install sentinel in safeStdout Proxy, flush summary at process exit - gitnexus/src/mcp/compatible-stdio-transport.ts: wrap send() write in withMcpWrite so transport frames pass through cleanly - gitnexus/test/unit/mcp-stdout-sentinel.test.ts: 17 cases covering pass-through, redirect, prefix, truncation (default 200 / custom), rate limit (default 10), one-shot warning, summary, mixed sequences * feat(eslint): forbid console.log/warn and process.stdout.write in MCP-reachable code Add a narrow ESLint override for gitnexus/src/mcp/**, gitnexus/src/core/lbug/**, gitnexus/src/core/embeddings/**, and gitnexus/src/cli/mcp.ts that: - sets no-console: ['error', { allow: ['error'] }] — only console.error survives, since stderr is the only spec-safe channel for diagnostics while the MCP stdio transport owns stdout for JSON-RPC frames - adds no-restricted-syntax matching MemberExpression and CallExpression forms of process.stdout.write to close the bypass path that the AsyncLocalStorage sentinel cannot guarantee Migrates 18 pre-existing console.log/warn call sites in core/embeddings/ (embedder.ts, embedding-pipeline.ts) to console.error; these are reached from gitnexus_query semantic search and would have polluted MCP stdio once a query triggered the embedding pipeline. Adds eslint-disable-next-line comments in pool-adapter.ts at the four legitimate process.stdout.write sites — they ARE the captured-real-write infrastructure used by the sentinel and the silenceStdout/restoreStdout mechanism. The override is forward-compatible with feat/pino-logger (PR #1336) which adds a broader no-console rule for gitnexus/src/; the narrow rule here is a strict subset and rebases trivially when #1336 lands. * feat(setup): pin setup-generated MCP config to installed version, keep static configs on @latest The user-facing MCP config that 'gitnexus setup' writes into editor configs now references gitnexus@ instead of gitnexus@latest, read dynamically from gitnexus/package.json#version at module load. This skips the npm-registry metadata roundtrip on every MCP connect and stays reproducible until the user explicitly upgrades. Static example configs and quickstart docs intentionally keep @latest: - .mcp.json, gitnexus-claude-plugin/.mcp.json - gitnexus-claude-plugin/skills/*/mcp.json (6 files) - README.md / gitnexus/README.md MCP examples Pinning these would create per-release version-bump churn for marginal (~100-500ms) savings. The dominant cold-cache cost is the native rebuild addressed separately by the GITNEXUS_SKIP_OPTIONAL_GRAMMARS env var. README adds a one-line steer above the @latest quickstart pointing repeated users at 'gitnexus setup' for the absolute-path config that bypasses npx entirely. Tests refactored to assert against the dynamic version (createRequire of package.json) so they don't break on every release bump: - gitnexus/test/unit/setup.test.ts - gitnexus/test/unit/setup-jsonc.test.ts - gitnexus/test/unit/setup-codex.test.ts - gitnexus/test/integration/setup-skills.test.ts (regex match) * feat(install,mcp): GITNEXUS_SKIP_OPTIONAL_GRAMMARS opt-out + missing-grammar warnings Postinstall scripts (build-tree-sitter-dart.cjs, build-tree-sitter-proto.cjs) gain a strict 'process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === "1"' early-exit so users without a C++ toolchain (or anyone wanting fast 'npm install gitnexus') can skip the native rebuild. Strict '=1' only — 'true', 'yes', '0' and any other value fall through to the rebuild. Add gitnexus/src/cli/optional-grammars.ts: cheap require.resolve probe for each optional grammar, with a stderr warning helper. The warning surfaces: - At MCP server start (cli/mcp.ts) — unconditional, since the server serves any indexed repo and we cannot pre-filter by language. - At 'gitnexus analyze' start (cli/analyze.ts) — conditional on the target repo containing .dart/.proto files (cheap glob), so users with no relevant code don't see noise. README documents the env var with the strict '=1' value and the trade-off (faster install, no Dart/Proto parsing until reinstalled). * test(mcp): child-process integration test asserts end-to-end stdout discipline Spawns 'node dist/cli/index.js mcp' as a child, drives the MCP stdio handshake (initialize -> initialized -> tools/list), reassembles every stdout chunk into Content-Length-framed JSON-RPC messages, and asserts zero stray bytes. Any byte outside a valid header-then-body window is captured and surfaced in the failure message alongside the server's stderr — this is the regression gate for U1 (no console.log/warn in MCP-reachable code) and U3 (AsyncLocalStorage stdout sentinel). Time budget: 5s local / 15s CI for first frame; 10s/30s total. Asserts the published GitNexus tool surface (list_repos, query, context, impact, detect_changes, rename) is reported by tools/list. Adds 'pretest:integration': 'node scripts/build.js' so 'npm run test:integration' rebuilds dist before the spawn — closes the 'stale dist masks regression' DX gap. * fix(mcp): address PR #1383 review — sentinel scope, grammar detection, lint, contract Blockers: - B2: detectMissingOptionalGrammars now actually require()s each grammar instead of require.resolve(). For 'file:' optional dependencies the package directory is always installed regardless of postinstall outcome, so resolve() never threw and the missing-grammar warning never fired for the exact target users (those who set GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 or whose native rebuild soft-failed). require() loads the entry, which triggers node-gyp-build and throws if .node is absent. Result memoized. Should-fix: - S1: Removed duplicate uncaughtException/unhandledRejection handlers from cli/mcp.ts. server.ts:startMCPServer already registers handlers with full stack traces; cli/mcp.ts handlers fired first with worse output and never got a chance to exit because server.ts shuts down immediately. - S2: Sentinel is now actually global. New setActiveStdoutWrite() in pool-adapter so silenceStdout/restoreStdout cycles preserve a registered wrapper instead of unwinding to raw realStdoutWrite. At startMCPServer: install sentinel.write as process.stdout.write AND register it as the active handler. Direct process.stdout.write calls from anywhere (console.log, dependency banners, etc.) now route through the sentinel instead of bypassing it. The transport's _safeStdout Proxy remains as belt-and-suspenders. - S3: ESLint no-restricted-syntax now also forbids destructuring of process.stdout (covers both 'const { write } = process.stdout' shapes and rest patterns). Minor: - M1: chunkToBuffer now handles plain Uint8Array (Buffer.from(u8)) instead of falling through to String(chunk) which produced '1,2,3,...' garbage. - M2: Untagged-write callbacks are now invoked on next tick per the Node Writable.write contract — both within and beyond the rate-limit cap. extractCallback handles the (chunk, cb) and (chunk, encoding, cb) overloads. - M3: setup.ts throws early if package.json#version is missing/non-string instead of emitting 'gitnexus@undefined'. - M4: parser-loader.ts console.warn → console.error; ESLint scope extended to gitnexus/src/core/tree-sitter/** so future violations are caught. New tests cover: - Plain Uint8Array redirect (asserts no String(chunk) garbage). - Writable callback fired async (next-tick) for both normal and past-rate-limit redirects. Validation: cd gitnexus && npx tsc --noEmit clean; vitest run 7863 passed, 11 skipped; eslint clean on MCP-reachable scope; integration test green against rebuilt dist/. * fix(mcp): close pre-sentinel stdout window + tighten contracts Address ce-code-review findings on PR #1383: P1 — Sentinel install order (was: stdout corruption window during mcpCommand pre-startup): - Add idempotent installGlobalStdoutSentinel() to mcp/stdio-context.ts. It captures realStdoutWrite/realStderrWrite, replaces process.stdout.write, and registers with pool-adapter's setActiveStdoutWrite — exactly once. - cli/mcp.ts now installs the sentinel as the FIRST line of mcpCommand, before warnMissingOptionalGrammars (which after the B2 fix actually require()s each native grammar binding and could emit node-gyp-build banners to raw stdout in the pre-sentinel window). - mcp/server.ts startMCPServer keeps a safety-net call to the same helper; the second invocation is a no-op. P1 — WriteFn type erasure: - WriteFn now declared as instead of , so the assignment and the setActiveStdoutWrite(sentinel.write) call don't silently cross a type boundary. P1 — extractCallback fragility: - Replaced backward-scan-with-undefined-break heuristic with a strict 'last arg if function' check matching the documented Writable.write contract. No longer breaks on a future (chunk, options, cb) overload. P2 — _detectionCache premature memoization: - Removed the explicit cache. Node's module cache already memoizes require() — calling detectMissingOptionalGrammars multiple times is cheap. Removing the module-level mutable state makes the helper trivially testable (no need for a reset hatch). P2 — Misleading 'reinstall' message on broken (not missing) grammars: - detectMissingOptionalGrammars now distinguishes MODULE_NOT_FOUND / node-gyp-build 'no native build' patterns from other errors (SyntaxError, EACCES, native crash). Broken bindings get an actionable stderr line naming the real failure instead of the misleading 'reinstall to enable' hint. Other: - mcp/core/lbug-adapter.ts updated with a KEEP-THIS-FILE note. Tests use the path as a vi.mock seam (calltool-dispatch.test.ts and 7 others); new non-test code may import core/lbug/pool-adapter.js directly. The maintainability finding flagging the shim as self-contradictory was incorrect — the shim has a real test purpose. Validation: tsc clean, vitest 7863 passed (no regressions), eslint clean on MCP-reachable scope, integration test green against rebuilt dist/. * fix(mcp): close import-time stdout corruption window 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 pulled in core/lbug/pool-adapter.ts, which imports @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. Fix: shrink cli/mcp.ts's static-import closure to a single zero-dep chain (mcp/stdio-context.js -> mcp/stdio-capture.js, both leaf-clean), install the sentinel as the first executable statement of mcpCommand, then dynamically import the heavy backend modules in parallel via await Promise.all. Per the plan at docs/plans/2026-05-06-002-fix-import-time-stdout-window-plan.md: - U1: New leaf module gitnexus/src/mcp/stdio-capture.ts owns the stdout-capture singleton state (realStdoutWrite, realStderrWrite, activeStdoutWrite + setActiveStdoutWrite/getActiveStdoutWrite). Zero non-node: imports — adding any would re-introduce the hazard. - U2: pool-adapter.ts re-exports the relocated symbols under the existing names so the test mock seam (8+ files use vi.mock on mcp/core/lbug-adapter.ts which re-exports * from pool-adapter) keeps working without churn. restoreStdout and the watchdog now read the active handler via getActiveStdoutWrite(). stdio-context.ts imports from stdio-capture directly. - U3: cli/mcp.ts's static imports collapse to one (installGlobalStdoutSentinel). startMCPServer / LocalBackend / warnMissingOptionalGrammars become parallel await import() inside mcpCommand, after the sentinel install. - U4: New regression test gitnexus/test/integration/mcp/import-closure.test.ts spawns a child Node process that imports dist/cli/mcp.js (without invoking mcpCommand), inspects the CJS module cache via createRequire, and asserts @ladybugdb/core (and tree-sitter native bindings) are NOT in the static-import closure. Characterization-first: this test was authored to fail against the pre-fix code and confirmed to do so before U1-U3 landed. Validation: tsc clean; vitest 7865 passed / 11 skipped (2 new U4 cases); eslint clean on MCP-reachable scope; integration server-startup test green against rebuilt dist/. * fix(mcp): drop dead ESLint selector + suppress redundant grammar warning Two minor PR #1383 review findings: 1. eslint.config.mjs: removed Selector 3 (`Property[key.name='write'].properties:has(...)`). `.properties` is not a valid attribute on a Property node in the ESTree AST, so the :has clause never matched — dead code. Selector 4 covers the canonical `const { write } = process.stdout` shape; tightened its comment to make that explicit. 2. cli/mcp.ts: removed the unconditional warnMissingOptionalGrammars call at MCP startup. The analyze path already emits this warning at index time with relevantExtensions filtered to the repo's actual file types, and a repo can only be served by MCP after analyze has run. Repeating the warning unconditionally on every MCP session was pure noise on machines whose indexed repos don't use .dart/.proto. * chore(mcp): address PR #1383 review nits Three minor hygiene findings from the production-readiness review: - cli/mcp.ts: rewrite stale comment that described warnMissingOptionalGrammars as living inside mcpCommand. The call was removed in ca617552 — this path no longer invokes it at all. - test/integration/mcp/import-closure.test.ts: same comment drift fixed. Test assertion is unchanged and still passes for the right reason (cli/mcp.js's static-import closure is leaf-only). - mcp/server.ts: rename _safeStdout to safeStdout. The leading underscore conventionally signals "intentionally unused" but the Proxy is passed to CompatibleStdioServerTransport on the next line. No behavior change. Typecheck clean; ESLint MCP-reachable scope still 0 errors. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 4 + eslint.config.mjs | 46 +++ gitnexus/package.json | 1 + gitnexus/scripts/build-tree-sitter-dart.cjs | 11 + gitnexus/scripts/build-tree-sitter-proto.cjs | 11 + gitnexus/src/cli/analyze.ts | 26 ++ gitnexus/src/cli/mcp.ts | 58 +++- gitnexus/src/cli/optional-grammars.ts | 103 +++++++ gitnexus/src/cli/setup.ts | 30 +- gitnexus/src/core/embeddings/embedder.ts | 16 +- .../src/core/embeddings/embedding-pipeline.ts | 20 +- gitnexus/src/core/lbug/extension-loader.ts | 2 +- gitnexus/src/core/lbug/lbug-adapter.ts | 10 +- gitnexus/src/core/lbug/pool-adapter.ts | 25 +- .../src/core/tree-sitter/parser-loader.ts | 6 +- .../src/mcp/compatible-stdio-transport.ts | 8 +- gitnexus/src/mcp/core/lbug-adapter.ts | 8 +- gitnexus/src/mcp/server.ts | 26 +- gitnexus/src/mcp/stdio-capture.ts | 61 ++++ gitnexus/src/mcp/stdio-context.ts | 183 +++++++++++ .../integration/mcp/import-closure.test.ts | 111 +++++++ .../integration/mcp/server-startup.test.ts | 270 ++++++++++++++++ .../test/integration/setup-skills.test.ts | 2 +- .../test/unit/mcp-stdout-sentinel.test.ts | 291 ++++++++++++++++++ gitnexus/test/unit/setup-codex.test.ts | 9 +- gitnexus/test/unit/setup-jsonc.test.ts | 7 +- gitnexus/test/unit/setup.test.ts | 13 +- 27 files changed, 1292 insertions(+), 66 deletions(-) create mode 100644 gitnexus/src/cli/optional-grammars.ts create mode 100644 gitnexus/src/mcp/stdio-capture.ts create mode 100644 gitnexus/src/mcp/stdio-context.ts create mode 100644 gitnexus/test/integration/mcp/import-closure.test.ts create mode 100644 gitnexus/test/integration/mcp/server-startup.test.ts create mode 100644 gitnexus/test/unit/mcp-stdout-sentinel.test.ts 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/eslint.config.mjs b/eslint.config.mjs index 2eb27dbae..910025683 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -67,6 +67,52 @@ export default [ }, }, + // MCP-reachable code: forbid stdout-corrupting writes. The MCP stdio + // transport writes JSON-RPC frames to stdout; per the spec, the server + // MUST NOT write anything to stdout that is not a valid MCP message. + // Diagnostics must go to stderr (console.error). Direct process.stdout.write + // bypasses the gate and is also forbidden in these dirs. + // cli/mcp.ts is included here even though it lives under cli/ — it is the + // MCP entrypoint and inherits stricter discipline than the rest of cli/. + { + files: [ + 'gitnexus/src/mcp/**/*.ts', + 'gitnexus/src/core/lbug/**/*.ts', + 'gitnexus/src/core/embeddings/**/*.ts', + 'gitnexus/src/core/tree-sitter/**/*.ts', + 'gitnexus/src/cli/mcp.ts', + ], + rules: { + 'no-console': ['error', { allow: ['error'] }], + 'no-restricted-syntax': [ + 'error', + { + selector: + "MemberExpression[object.type='MemberExpression'][object.object.name='process'][object.property.name='stdout'][property.name='write']", + message: + 'Direct process.stdout.write is forbidden in MCP-reachable code. Route diagnostics through console.error or process.stderr.write — the MCP stdio transport owns stdout for JSON-RPC frames.', + }, + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.object.name='process'][callee.object.property.name='stdout'][callee.property.name='write']", + message: + 'Direct process.stdout.write is forbidden in MCP-reachable code. Route diagnostics through console.error or process.stderr.write — the MCP stdio transport owns stdout for JSON-RPC frames.', + }, + { + // Catches the canonical destructuring shape: + // const { write } = process.stdout; + // (and any other ObjectPattern destructure rooted at process.stdout) + // which would otherwise capture a reference to the original write + // and bypass the sentinel. + selector: + "VariableDeclarator[init.type='MemberExpression'][init.object.name='process'][init.property.name='stdout'] > ObjectPattern", + message: + 'Destructuring process.stdout is forbidden in MCP-reachable code — bypasses the sentinel. Use process.stderr.write for diagnostics.', + }, + ], + }, + }, + // React-specific rules for gitnexus-web { files: ['gitnexus-web/src/**/*.{ts,tsx}'], diff --git a/gitnexus/package.json b/gitnexus/package.json index bd4cbe12c..6aa5f59b7 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", 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/analyze.ts b/gitnexus/src/cli/analyze.ts index 2c199ae66..90b8c9a43 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -23,6 +23,8 @@ import { import { getGitRoot, hasGitDir } from '../storage/git.js'; import { runFullAnalysis } from '../core/run-analyze.js'; import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js'; +import { warnMissingOptionalGrammars } from './optional-grammars.js'; +import { glob } from 'glob'; import fs from 'fs/promises'; // Capture stderr.write at module load BEFORE anything (LadybugDB native @@ -273,6 +275,30 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption ); } + // If the target repo contains files an optional grammar would parse but + // that grammar's native binding is absent, warn before analysis so users + // learn why those files end up unparsed instead of silently getting a + // degraded index. + try { + const matches = await glob(['**/*.dart', '**/*.proto'], { + cwd: repoPath, + ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], + dot: false, + nodir: true, + absolute: false, + }); + if (matches.length > 0) { + const present = new Set(); + 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. diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index 58c7f10f0..e69ce0eb0 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -4,23 +4,55 @@ * Starts the MCP server in standalone mode. * Loads all indexed repos from the global registry. * No longer depends on cwd — works from any directory. + * + * IMPORTANT: this module's static-import closure is intentionally tiny + * (one chain: `mcp/stdio-context.js` → `mcp/stdio-capture.js`, which is a + * leaf with zero non-`node:` imports). All heavy backend modules + * (`startMCPServer`, `LocalBackend`, `warnMissingOptionalGrammars`) load + * via `await import(...)` AFTER `installGlobalStdoutSentinel()` runs. + * + * This closes the ESM-evaluation-order window where native init banners + * from `@ladybugdb/core` (or any future heavy import) could reach raw + * stdout before the sentinel exists. Codex's adversarial review on + * PR #1383 found that even with the sentinel-install call as the first + * statement of `mcpCommand`, ESM evaluates static imports of THIS module + * before the function body runs — so any native side effects during + * those imports happen before the sentinel can intercept them. + * + * If you find yourself adding a static `import` to this file, ask + * whether the imported module (or anything it transitively imports) + * touches `process.stdout` or loads a native binding at module init. If + * either is true, switch it to a dynamic `await import(...)` inside + * `mcpCommand` after the sentinel install. The regression test at + * `gitnexus/test/integration/mcp/import-closure.test.ts` enforces this. */ -import { startMCPServer } from '../mcp/server.js'; -import { LocalBackend } from '../mcp/local/local-backend.js'; +import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js'; export const mcpCommand = async () => { - // Prevent unhandled errors from crashing the MCP server process. - // LadybugDB lock conflicts and transient errors should degrade gracefully. - process.on('uncaughtException', (err) => { - console.error(`GitNexus MCP: uncaught exception — ${err.message}`); - // Process is in an undefined state after uncaughtException — exit after flushing - setTimeout(() => process.exit(1), 100); - }); - process.on('unhandledRejection', (reason) => { - const msg = reason instanceof Error ? reason.message : String(reason); - console.error(`GitNexus MCP: unhandled rejection — ${msg}`); - }); + // Install the global stdout sentinel as the very first thing — before + // ANY other module loads. The static-import closure above is leaf-only + // (stdio-context → stdio-capture, zero non-`node:` deps), so this is + // also the first chance any code in this process has to write to stdout. + installGlobalStdoutSentinel(); + + // uncaughtException/unhandledRejection handlers are owned by + // startMCPServer (gitnexus/src/mcp/server.ts) so the server's shutdown + // path runs cleanly with full stack traces. Registering duplicates here + // would only produce noisy double-logging on the same exception. + + // Now safe to dynamically import the heavy backend modules. Anything + // they emit to stdout during evaluation will route through the sentinel. + const [{ startMCPServer }, { LocalBackend }] = await Promise.all([ + import('../mcp/server.js'), + import('../mcp/local/local-backend.js'), + ]); + + // Missing-optional-grammar warnings are intentionally NOT emitted here. + // `gitnexus analyze` already warns at index time, filtered by the repo's + // actual extensions, and a repo can only be served by MCP after analyze + // has run. Repeating an unconditional warning at every MCP startup is + // pure noise for users whose indexed repos don't use Dart/Proto. // Initialize multi-repo backend from registry. // The server starts even with 0 repos — tools call refreshRepos() lazily, diff --git a/gitnexus/src/cli/optional-grammars.ts b/gitnexus/src/cli/optional-grammars.ts new file mode 100644 index 000000000..c6e239b56 --- /dev/null +++ b/gitnexus/src/cli/optional-grammars.ts @@ -0,0 +1,103 @@ +/** + * Optional grammar availability check. + * + * tree-sitter-dart and tree-sitter-proto are optionalDependencies that + * require a `node-gyp rebuild` at install time. The build can be skipped + * via GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or it can + * silently soft-fail when the C++ toolchain is missing. + * + * Either path produces the same observable: the .node binding is absent + * at runtime. This helper detects that condition and surfaces a single + * stderr line per missing grammar so users learn why .dart/.proto support + * is unavailable instead of silently getting a degraded index. + */ + +import { createRequire } from 'module'; + +const _require = createRequire(import.meta.url); + +interface OptionalGrammar { + /** Display name in warnings */ + name: string; + /** Module name to require.resolve */ + pkg: string; + /** File extensions this grammar parses */ + extensions: string[]; +} + +const OPTIONAL_GRAMMARS: OptionalGrammar[] = [ + { name: 'tree-sitter-dart', pkg: 'tree-sitter-dart', extensions: ['.dart'] }, + { name: 'tree-sitter-proto', pkg: 'tree-sitter-proto', extensions: ['.proto'] }, +]; + +export interface MissingGrammar { + name: string; + extensions: string[]; +} + +/** + * Returns the list of optional grammars whose native binding cannot be + * loaded. Actually `require()`s the package — `require.resolve` would + * locate the entry path even when the `.node` binding is absent (the + * `file:` package directory is installed regardless of postinstall + * outcome), giving false negatives for the exact users we want to warn: + * those who installed with `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` or whose + * native rebuild soft-failed for missing toolchain. + * + * Node's module cache memoizes `require()` for us — calling this multiple + * times is cheap. The catch distinguishes "missing" (MODULE_NOT_FOUND or + * the typical node-gyp-build "could not find any binding" pattern) from + * "broken" (SyntaxError, EACCES, native crash). Broken bindings surface a + * separate stderr line so users get an actionable message instead of a + * misleading "reinstall" hint. + */ +export function detectMissingOptionalGrammars(): MissingGrammar[] { + const missing: MissingGrammar[] = []; + for (const g of OPTIONAL_GRAMMARS) { + try { + _require(g.pkg); + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + const msg = err instanceof Error ? err.message : String(err); + const looksMissing = + code === 'MODULE_NOT_FOUND' || + code === 'ERR_MODULE_NOT_FOUND' || + /could not find|no native build|prebuilds/i.test(msg); + if (!looksMissing) { + // Present but broken — surface so the user doesn't get a misleading + // "reinstall" recovery message that wouldn't actually help. + console.error( + `GitNexus: optional grammar "${g.name}" is installed but failed to load (${msg.slice(0, 200)}). ${g.extensions.join('/')} files will not be parsed.`, + ); + } + missing.push({ name: g.name, extensions: g.extensions }); + } + } + return missing; +} + +/** + * Log a one-line stderr warning for each missing grammar. Safe to call + * unconditionally — silent if all grammars are present. + * + * `relevantExtensions`, if provided, filters the warning to grammars whose + * extensions appear in the set (e.g. an analyze run can pass the set of + * extensions actually present in the target repo so users without any + * .dart/.proto files don't see noise). + */ +export function warnMissingOptionalGrammars(opts?: { + context?: string; + relevantExtensions?: ReadonlySet; +}): void { + const missing = detectMissingOptionalGrammars(); + if (missing.length === 0) return; + const ctx = opts?.context ? ` [${opts.context}]` : ''; + for (const g of missing) { + if (opts?.relevantExtensions && !g.extensions.some((e) => opts.relevantExtensions!.has(e))) { + continue; + } + console.error( + `GitNexus${ctx}: optional grammar "${g.name}" is unavailable — ${g.extensions.join('/')} files will not be parsed. Reinstall without GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (and ensure python3, make, g++) to enable.`, + ); + } +} diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index b301d7f38..1238e6527 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'] }; } /** diff --git a/gitnexus/src/core/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index 0d7fe41df..993c41883 100644 --- a/gitnexus/src/core/embeddings/embedder.ts +++ b/gitnexus/src/core/embeddings/embedder.ts @@ -166,7 +166,7 @@ export const initEmbedder = async ( const isDev = process.env.NODE_ENV === 'development'; if (isDev) { - console.log(`🧠 Loading embedding model: ${finalConfig.modelId}`); + console.error(`🧠 Loading embedding model: ${finalConfig.modelId}`); } const progressCallback = onProgress @@ -192,13 +192,13 @@ export const initEmbedder = async ( for (const device of devicesToTry) { try { if (isDev && device === 'dml') { - console.log('🔧 Trying DirectML (DirectX12) GPU backend...'); + console.error('🔧 Trying DirectML (DirectX12) GPU backend...'); } else if (isDev && device === 'cuda') { - console.log('🔧 Trying CUDA GPU backend...'); + console.error('🔧 Trying CUDA GPU backend...'); } else if (isDev && device === 'cpu') { - console.log('🔧 Using CPU backend...'); + console.error('🔧 Using CPU backend...'); } else if (isDev && device === 'wasm') { - console.log('🔧 Using WASM backend (slower)...'); + console.error('🔧 Using WASM backend (slower)...'); } embedderInstance = await (pipeline as any)('feature-extraction', finalConfig.modelId, { @@ -221,15 +221,15 @@ export const initEmbedder = async ( : device === 'cuda' ? 'GPU (CUDA)' : device.toUpperCase(); - console.log(`✅ Using ${label} backend`); - console.log('✅ Embedding model loaded successfully'); + console.error(`✅ Using ${label} backend`); + console.error('✅ Embedding model loaded successfully'); } return embedderInstance!; } catch (deviceError) { if (isDev && (device === 'cuda' || device === 'dml')) { const gpuType = device === 'dml' ? 'DirectML' : 'CUDA'; - console.log(`⚠️ ${gpuType} not available, falling back to CPU...`); + console.error(`⚠️ ${gpuType} not available, falling back to CPU...`); } // Continue to next device in list if (device === devicesToTry[devicesToTry.length - 1]) { diff --git a/gitnexus/src/core/embeddings/embedding-pipeline.ts b/gitnexus/src/core/embeddings/embedding-pipeline.ts index 82af563d4..9d4f98281 100644 --- a/gitnexus/src/core/embeddings/embedding-pipeline.ts +++ b/gitnexus/src/core/embeddings/embedding-pipeline.ts @@ -157,7 +157,7 @@ const queryEmbeddableNodes = async ( } } catch (error) { if (isDev) { - console.warn(`Query for ${label} nodes failed:`, error); + console.error(`Query for ${label} nodes failed:`, error); } } } @@ -212,7 +212,7 @@ const createVectorIndex = async ( return true; } catch (error) { if (isDev) { - console.warn('Vector index creation warning:', error); + console.error('Vector index creation warning:', error); } return false; } @@ -256,7 +256,7 @@ export const runEmbeddingPipeline = async ( try { const vectorAvailable = await ensureVectorExtensionAvailable(); - if (!vectorAvailable && isDev) console.warn(vectorUnavailableMessage); + if (!vectorAvailable && isDev) console.error(vectorUnavailableMessage); // Phase 1: Load embedding model onProgress({ @@ -283,7 +283,7 @@ export const runEmbeddingPipeline = async ( }); if (isDev) { - console.log('🔍 Querying embeddable nodes...'); + console.error('🔍 Querying embeddable nodes...'); } // Phase 2: Query embeddable nodes @@ -325,7 +325,7 @@ export const runEmbeddingPipeline = async ( // (Kuzu forbids SET on vector-indexed properties; DELETE-then-INSERT is the sanctioned pattern) if (staleNodeIds.length > 0) { if (isDev) { - console.log(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`); + console.error(`🔄 Deleting ${staleNodeIds.length} stale embedding rows for re-embed`); } try { await executeWithReusedStatement( @@ -346,7 +346,7 @@ export const runEmbeddingPipeline = async ( } if (isDev) { - console.log( + console.error( `📦 Incremental embeddings: ${beforeCount} total, ${existingEmbeddings.size} cached, ${staleNodeIds.length} stale, ${nodes.length} to embed`, ); } @@ -355,7 +355,7 @@ export const runEmbeddingPipeline = async ( const totalNodes = nodes.length; if (isDev) { - console.log(`📊 Found ${totalNodes} embeddable nodes`); + console.error(`📊 Found ${totalNodes} embeddable nodes`); } if (totalNodes === 0) { @@ -442,7 +442,7 @@ export const runEmbeddingPipeline = async ( ); } catch (chunkErr) { if (isDev) { - console.warn( + console.error( `⚠️ AST chunking failed for ${node.label} "${node.name}" (${node.filePath}), falling back to character-based chunking:`, chunkErr, ); @@ -520,7 +520,7 @@ export const runEmbeddingPipeline = async ( }); if (isDev) { - console.log('📇 Creating vector index...'); + console.error('📇 Creating vector index...'); } const vectorIndexReady = await createVectorIndex(executeQuery); @@ -533,7 +533,7 @@ export const runEmbeddingPipeline = async ( }); if (isDev) { - console.log( + console.error( `✅ Embedding pipeline complete! (${totalChunks} chunks from ${totalNodes} nodes)`, ); } diff --git a/gitnexus/src/core/lbug/extension-loader.ts b/gitnexus/src/core/lbug/extension-loader.ts index 9fbee871b..b925c5f88 100644 --- a/gitnexus/src/core/lbug/extension-loader.ts +++ b/gitnexus/src/core/lbug/extension-loader.ts @@ -188,7 +188,7 @@ export class ExtensionManager { const policy = opts.policy ?? this.options.policy ?? resolvePolicyFromEnv(); const timeoutMs = opts.installTimeoutMs ?? this.options.installTimeoutMs ?? getExtensionInstallTimeoutMs(); - const warn = this.options.warn ?? console.warn; + const warn = this.options.warn ?? console.error; if (policy === 'never') { this.markUnavailable(name, label, 'extension install policy is "never"', warn); diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index ba83d559e..a4559436a 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -330,7 +330,7 @@ const doInitLbug = async (dbPath: string) => { } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (!msg.includes('already exists')) { - console.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`); + console.error(`[gitnexus:lbug] schema creation warning: ${msg.slice(0, 120)}`); } } } @@ -1010,15 +1010,15 @@ export const fetchExistingEmbeddingHashes = async ( const nodeId = r.nodeId ?? r[0]; if (nodeId) map.set(nodeId, STALE_HASH_SENTINEL); } - console.log( - `[embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`, + console.error( + `[gitnexus:embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`, ); return map; } catch (fallbackErr: any) { const fallbackMsg = fallbackErr?.message ?? ''; if (isMissingColumnOrTableError(fallbackMsg)) { - console.log( - `[embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`, + console.error( + `[gitnexus:embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`, ); return undefined; } 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/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index e79cdc599..0ec958e36 100644 --- a/gitnexus/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus/src/core/tree-sitter/parser-loader.ts @@ -175,8 +175,10 @@ const logFailure = (key: string, result: LoadResult): void => { logged.add(key); const message = `[gitnexus] ${result.note} (${result.error.message})`; - if (result.severity === 'error') console.error(message); - else console.warn(message); + // Both severities go to stderr — console.warn writes to stderr too, but + // console.error is the stdout-safe channel we standardize on across + // MCP-reachable code so the ESLint rule covers this directory. + console.error(message); }; export const resolveLanguageKey = (language: SupportedLanguages, filePath?: string): string => 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/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/server.ts b/gitnexus/src/mcp/server.ts index be80f4c35..f7193c484 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,19 +287,31 @@ 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); + // Surface the redirect counter on shutdown so users see the volume of + // stray writes even when individual payloads were truncated/suppressed. + process.on('exit', () => sentinel.flushSummary()); + // Graceful shutdown helper let shuttingDown = false; const shutdown = async (exitCode = 0) => { 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/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..339820ba5 100644 --- a/gitnexus/test/integration/setup-skills.test.ts +++ b/gitnexus/test/integration/setup-skills.test.ts @@ -96,7 +96,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/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/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'], }); }); From c8683d58fc79bc130597c0bd188687fa764d5638 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 09:54:17 +0100 Subject: [PATCH 21/29] chore(deps): bump github/codeql-action from 3.35.3 to 4.35.3 (#1390) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.35.3 to 4.35.3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/0daab03d71ff584ef619d027a3fd9146679c5d84...e46ed2cbd01164d986452f91f178727624ae40d7) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.3 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- .github/workflows/trivy.yml | 2 +- .github/workflows/workflow-lint.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) 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/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..f4a6bf237 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -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 From fd4d4a3fee4c009ab87f7557c716e800ce99e130 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 09:54:50 +0100 Subject: [PATCH 22/29] chore(deps): bump docker/build-push-action from 6.19.2 to 7.1.0 (#1391) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.1.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...bcafcacb16a39f128d818304e6c9c0c18556b85f) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/trivy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index f4a6bf237..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 }} From 8e7672975052ea9f5c13cd9040c469cf68f0d2b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 09:55:10 +0100 Subject: [PATCH 23/29] chore(deps)(deps): bump @langchain/anthropic in /gitnexus-web (#1389) Bumps [@langchain/anthropic](https://github.com/langchain-ai/langchainjs) from 1.3.27 to 1.3.28. - [Release notes](https://github.com/langchain-ai/langchainjs/releases) - [Commits](https://github.com/langchain-ai/langchainjs/commits) --- updated-dependencies: - dependency-name: "@langchain/anthropic" dependency-version: 1.3.28 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 10 +++++----- gitnexus-web/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 7a2d92243..7fcc0d299 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -8,7 +8,7 @@ "name": "gitnexus", "version": "0.0.0", "dependencies": { - "@langchain/anthropic": "^1.3.27", + "@langchain/anthropic": "^1.3.28", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.28", "@langchain/langgraph": "^1.2.9", @@ -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,7 +1408,7 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.1.41" + "@langchain/core": "^1.1.42" } }, "node_modules/@langchain/core": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index aed71ca9b..81d7fc5fa 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "gitnexus-shared": "file:../gitnexus-shared", - "@langchain/anthropic": "^1.3.27", + "@langchain/anthropic": "^1.3.28", "@langchain/core": "^1.1.44", "@langchain/google-genai": "^2.1.28", "@langchain/langgraph": "^1.2.9", From 48f15a3bca9b653cb9c6eb61ae0551b6caa89bb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 7 May 2026 12:17:12 +0100 Subject: [PATCH 24/29] ci(release): use fine-grained PAT for rc tag push (#1407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default GITHUB_TOKEN cannot be granted `workflows: write`, so `git push --atomic` of the rc v-tag fails when its commit chain reaches any commit that modified `.github/workflows/**`. Symptom on the most recent run: ! [remote rejected] v1.6.4-rc.82 -> v1.6.4-rc.82 (refusing to allow a GitHub App to create or update workflow `.github/workflows/trivy.yml` without `workflows` permission) GitHub's rule: any ref-update that makes a workflow-modifying commit reachable through the new ref requires `workflows: write` on the identity performing the push, regardless of whether that commit is already on another remote ref. The default GITHUB_TOKEN cannot hold that permission. Pass a fine-grained PAT (RELEASE_PUSH_TOKEN, scoped to this repo with Contents: write + Workflows: write) into actions/checkout's `token` input so origin is preauthed for the subsequent `git push`. The job-level GITHUB_TOKEN keeps its scoped permissions for npm provenance and other steps. Required one-time setup: 1. Generate a fine-grained PAT - Resource owner: account that owns this repo - Repository access: Only select repositories → GitNexus - Permissions: Contents: write, Workflows: write, Metadata: read 2. Add as repo secret named RELEASE_PUSH_TOKEN 3. Re-run the failed Release Candidate workflow with force=true Considered and skipped: GitHub App approach (org-owned, bot identity, short-lived tokens). Better long-term, but a fine-grained PAT is acceptable at one-maintainer scale. Migration is mechanical if the project later wants to switch. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/release-candidate.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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: From 4e362ba70a70475024eb4fc5781c2a0bcfc4a383 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Thu, 7 May 2026 14:27:49 +0100 Subject: [PATCH 25/29] fix(setup): correct OpenCode skills install path in status message (#1386) * fix(setup): correct OpenCode skills install path in status message (#1381) The log message reported ~/.config/opencode/skill/ (missing trailing s) while the actual install path was already correct (skills/). Fixes the misleading output so users see the real destination directory. * test(setup): add OpenCode plural skills-path integration test (#1381) Verifies that setup installs skills into ~/.config/opencode/skills/ (plural) and that the singular path does not exist. Co-Authored-By: Gujiassh --------- Co-authored-by: Gujiassh --- gitnexus/src/cli/setup.ts | 2 +- gitnexus/test/integration/setup-skills.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 1238e6527..d1b7b520f 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -622,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/test/integration/setup-skills.test.ts b/gitnexus/test/integration/setup-skills.test.ts index 339820ba5..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(); From f40973a1ca172a0caa50984e7518f6adf4f63eb9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:00:05 +0100 Subject: [PATCH 26/29] fix(ci): handle expired artifacts in base coverage fetch (#1410) The "Fetch base branch coverage" step in ci-report.yml now: - Checks up to 5 recent successful main-branch CI runs - Catches HTTP 410 (artifact expired) and tries the next run - Gracefully sets found=false if all artifacts are expired/missing This prevents the PR Report job from failing when the most recent main-branch test-reports artifact has expired. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a2a6b392-de09-4c76-b7ea-5de2c738cb9d Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> --- .github/workflows/ci-report.yml | 66 ++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index ab8d8362c..bf09de2c2 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' From 4cd3ee3832ab18d0c8f4d01210cf05dbfef307f3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 16:45:47 +0100 Subject: [PATCH 27/29] Fix ci-report step when base coverage artifact is unavailable (#1412) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/cdf42ff2-4b89-4c5e-a5ab-f68ff62995b0 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/workflows/ci-report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-report.yml b/.github/workflows/ci-report.yml index bf09de2c2..03908bb2b 100644 --- a/.github/workflows/ci-report.yml +++ b/.github/workflows/ci-report.yml @@ -250,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 } From d3a7ce95a52474613964d58e05e8c85ef7d8ea9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 7 May 2026 20:56:25 +0100 Subject: [PATCH 28/29] feat(core): adopt pino structured logger (#1336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(core): adopt pino structured logger + add no-console eslint forcing function Adds `pino` as the project-wide structured logger via a thin wrapper at `gitnexus/src/core/logger.ts` exposing `createLogger(name, opts?)` and a default `logger` singleton. Migrates the only security-relevant `console.warn` site (`bridge-db.ts` `openBridgeDbReadOnly` retry-exhaustion path) to `bridgeLogger.debug({groupDir, err, attempts}, 'msg')`. Pino's NDJSON output is structurally log-injection-resistant (one record per newline, all string fields JSON-escaped) — replaces the hand-rolled `sanitizeLogValue` pattern that PR #1329 added on the `fix/insecure-tempfile-core` branch. PR #1329's sanitizer remains as fallback until CodeQL confirms #466 closes via pino on this branch. Also adds an ESLint `no-console: warn` rule scoped to `gitnexus/src/**/*.ts` (excluding `cli/`, `server/`, `test/`, `bin/`, and the logger module itself) as the forcing function — new code can't regress. Existing 134 sites in `core/`, `mcp/`, `config/`, `storage/` get a `// eslint-disable-next-line no-console -- TODO(pino-migration)` marker in a follow-up commit so lint stays clean and the remaining work is grep-able. Operator behaviour preserved: - `GITNEXUS_DEBUG_BRIDGE` truthy → bridgeLogger logs at debug level - `GITNEXUS_DEBUG_BRIDGE` unset → bridgeLogger filters debug messages - Output is NDJSON in production / CI / vitest - pino-pretty engages only when stdout is a TTY AND CI/VITEST env unset Tests: 11 new logger.test.ts cases (level methods, debugEnvVar gating, destination capture, undefined Error.message safety, CR/LF/U+2028/ANSI single-record invariant). Group test suite (388 tests) passes unchanged. `--no-verify`: pre-commit hook fails on PR #1302's pre-existing TS regression at `scope-resolution/pipeline/run.ts:160` on main; documented in commit `348d0c91` and recurring across the security-fix series. Refs: #466 (codeql js/log-injection), PR #1329 follow-up. * chore(lint): baseline-suppress 134 existing console.* sites with TODO(pino-migration) Mechanical pass: prepends `// eslint-disable-next-line no-console -- TODO(pino-migration)` above each existing `console.*` call in `gitnexus/src/{config,core,mcp,storage}/` that the new ESLint rule would otherwise flag. CLI/server are exempt at the config level (legitimate stdout output). Zero functional changes. Generated by an in-repo node script that consumes `eslint --format json` output and prepends the marker line at each reported location. Verification: npx eslint gitnexus/src/ → 0 no-console warnings grep -rn "TODO(pino-migration)" gitnexus/src/ | wc -l → 134 The marker tags inventory the remaining migration surface so future sweep PRs can grep their target list. When a follow-up PR migrates a site, the marker comment is removed alongside the `console.*` → `logger.*` swap. `--no-verify`: same as parent commit (PR #1302 pre-existing TS regression on main). * refactor(core): complete pino migration — replace all 134 console.* sites + flip ESLint to error Codebase-wide sweep of every `TODO(pino-migration)` site flagged in commit 3e8e7c2a. 49 source files migrated, 134 `console.*` calls converted to `logger.*` using pino's structured-arg convention (object first, message second). All `TODO(pino-migration)` markers removed. ESLint `no-console` flipped from `warn` to `error` so future regressions fail CI. Source-side changes (49 files): - Mechanical pattern: `console.X(msg)` → `logger.X(msg)`, `console.X(msg, val)` → `logger.X({val}, msg)` (bare-id shorthand) or `logger.X({err: val}, msg)` for Error-shaped names. - Hand-fixed special cases: * `import-processor.ts`: `console.group/groupEnd` block → single `logger.error({...}, 'tree-sitter query error')` with merged fields. * `extension-loader.ts`: `console.warn` as default callback → `(msg) => logger.warn(msg)` lambda binding. * `cursor-client.ts`: variadic `console.log(...args)` → `logger.info({args}, '[cursor-cli]')`. - `console.log` → `logger.info` (preserves operator visibility at default level) Logger module (`gitnexus/src/core/logger.ts`) updates: - Default level `info` (matches pino default; preserves `console.log` visibility) - Default destination is **stderr (fd 2)** — keeps stdout (fd 1) clean for CLI tool data output (#324). Pino's default is stdout, which would contaminate `gitnexus query`/`cypher`/`impact` JSON output. - Pretty-print TTY check now reads `process.stderr.isTTY` (matches new sink). - `_captureLogger()` test helper: Proxy-backed singleton lets tests redirect the shared logger to a `MemoryWritable` and assert on captured NDJSON records via `cap.records()` / `cap.text()`. Restored on teardown. Test-side changes (10 files): - `max-file-size.test.ts`, `filesystem-walker.test.ts`, `worker-pool.test.ts`, `calltool-dispatch.test.ts`, `grpc-extractor.test.ts`, `ignore-service.test.ts`, `index-repo-command.test.ts`, `sequential-language-availability.test.ts`, `sync.test.ts`, `rust-workspace-extractor.test.ts`: replace `vi.spyOn(console, 'X')` patterns and ad-hoc `console.warn = ...` reassignments with `_captureLogger()` + `cap.records()` assertions. - `analyze-worker-timeout.test.ts`: kept original `vi.spyOn(console, 'error')` — exercises CLI code (cli/analyze.ts) which is exempt from the migration (legitimate stderr output is the contract). ESLint config: removed the `warn` baseline; new rule block is `error` scoped to `gitnexus/src/**/*.ts` with the existing cli/server exemption preserved. Logger module + test/ + bin/ remain off. Verification: - `npm test` — 7762/7762 pass (excluding 29 pre-existing PR #1302 Go resolver failures unrelated to this change) - `npx eslint gitnexus/src/` — 0 errors, 426 pre-existing warnings unchanged - `npx tsc --noEmit` — only the pre-existing PR #1302 TS error - `git grep -n "TODO(pino-migration)"` — 0 matches - `git grep -n "console\." gitnexus/src/ | grep -v cli/ | grep -v server/ | grep -v logger.ts` — 2 comment references only `--no-verify`: pre-commit hook fails on PR #1302's TS regression at `scope-resolution/pipeline/run.ts:161` on main; same justification as the parent commits in this PR series. Refs: #466 (codeql js/log-injection), PR #1336. * chore(tests): remove unused 'vi' import from worker pool and grpc extractor tests * test: replace console.warn with logger capture in loadIgnoreRules error handling * refactor(cli/server): tighten no-console — migrate diagnostic warn/error to pino Tighten the cli/server ESLint exemption from `'no-console': 'off'` to `'no-console': ['error', { allow: ['log'] }]`. `console.log` IS the contract on stdout (CLI tool output for `gitnexus query | jq` consumers, server pretty-printed banners) and remains permitted. Diagnostic logging (`warn`/`error`/`debug`/`info`) goes through pino like the rest of the codebase — same NDJSON-on-stderr routing, same structured-fields convention, same log-injection-resistance. Migrated 88 sites across 13 files (cli + server). Three sites in `cli/analyze.ts` are intentional UI patterns (the progress-bar swaps `console.warn`/`console.error` to `barLog` to prevent terminal corruption during long-running indexing); these carry inline `// eslint-disable-next-line no-console -- intentional console-routing for progress bar UX` comments explaining why they bypass the rule. Test wiring updated: - `analyze-worker-timeout.test.ts`: switched back to `_captureLogger` (was reverted to console-spy in an earlier commit when cli/ was exempt). Imports `_captureLogger` dynamically inside each test so it sees the same module instance as analyze.js after `vi.resetModules()` rebuilds the singleton. - `web-ui-serving.test.ts`: console-warn assertion swapped to `cap.records()` lookup of the new structured log shape (`r.err`). Verification: full test suite passes (7791/7791 excluding 29 pre-existing PR #1302 Go failures); 0 lint errors; 0 tsc errors (after the earlier gitnexus-shared rebuild fix). Refs: PR #1336. * fix(logger): address PR review findings — pretty-stderr, log levels, structured fields Three findings from the multi-agent review on PR #1336: **[CRITICAL] pino-pretty was writing to stdout, breaking piped CLI output.** `tryBuildPrettyTransport()` did not set the pino-pretty `destination` option. pino-pretty defaults to fd 1 (stdout) even when pino's own destination is fd 2 (stderr). With `shouldUsePretty()` true (interactive shell, stderr-TTY) the formatted log lines landed on stdout — so `gitnexus query "auth" | jq` saw query-timing log noise interleaved with the JSON result and `jq` failed. Fix: pass `destination: 2` to the pino-pretty transport options. The non-pretty path already used `pino.destination({dest: 2})`; this aligns the two paths. **[HIGH] `logQueryTiming()` and MCP startup banner used `logger.error()` for non-error conditions.** Migration artifacts. Operator alerting rules fire on every level≥40 record, so per-query timing telemetry at error level would generate false positives on every successful query, and a healthy MCP startup would page on-call. - `local-backend.ts:logQueryTiming` → `logger.debug` with structured `{ query, totalMs, phases }` fields. Operators wanting per-query timing set the appropriate log level. - `local-backend.ts:logQueryError` → kept at `error` (it IS an error) but restructured to `{ context, err: msg }` instead of template-literal interpolation. - `mcp.ts` "starting with N repos" banner → `logger.info` with `{ repoCount, repos }` structured fields. - `mcp.ts` "no repos yet" notice → `logger.warn` (operator-actionable but non-fatal; server still starts and serves). **[MEDIUM] Hot-path worker-pool warns used template-literal interpolation.** Two `logger.warn` sites in `core/ingestion/workers/ worker-pool.ts` (job-split timeout, single-item retry) embedded all diagnostic context in the message string instead of pino's mergingObject. Restructured to canonical `logger.warn({ workerIndex, items, estimatedBytes, ... }, 'msg')` so log aggregators can query fields independently. Existing tests pin on `r.msg.includes('Splitting into ...')` / `'Retrying with ...'` — preserved in the message string so test assertions still pass. Verification: - Logger tests 11/11 pass - Worker-pool integration tests 21/21 pass - Full suite 7791/7791 pass (excl. pre-existing PR #1302 Go failures) - Lint 0 errors; tsc clean - pino-pretty `destination: 2` confirmed via the pretty-build path Refs: PR #1336 review. * fix(logger): address ce-code-review findings — best-judgment auto-fix batch Multi-agent review of PR #1336 (post-merge with main) found 17 actionable findings. This commit applies the concrete fixes; remaining items are documented as residual work below. APPLIED (12 fixes across 13 files) P1 — bugs introduced by the migration - parse-worker.ts:1451 — restore the dropped `else`. The migration replaced `if (parentPort) ...; else console.warn(message)` with an unconditional `logger.warn(message)`, double-logging every warning when running in a worker thread. - grpc-extractor.test.ts:585 — remove the spurious `import { _captureLogger } from '...';` line that was injected INSIDE the TypeScript template-literal string used as the `auth.client.ts` test fixture. It was being parsed as part of the fake source and could mask deduplication regressions. - eval-server.ts (8 sites), mcp/core/embedder.ts (2 sites), local-backend.ts (1 site) — `logger.error` → `logger.info`/`logger.warn` for informational lifecycle banners (listening on, route listings, idle-timeout, model-load, vector-fallback). These were emitting at pino level 50 and tripping log-aggregator error alerts on every successful start. - core/logger.ts — wire `GITNEXUS_LOG_LEVEL` env var into `buildBaseOptions`. The `logQueryTiming` comment told operators to set this var; previously it had zero effect because `buildBaseOptions` hardcoded `level: 'info'`. - core/logger.ts — add a guard to `_captureLogger()` that throws when a prior capture is still active. Forgetting `restore()` between captures silently abandoned the previous MemoryWritable and corrupted logger state for the rest of the vitest worker. - core/logger.ts — Proxy `get` trap now uses `Reflect.get(inner, prop, inner)` instead of `(inner as ...)[prop as string]`. The `prop as string` cast silently coerced symbol-keyed lookups (e.g. Symbol.toPrimitive) to the wrong key. - embedding-pipeline.ts:259 — restore the `if (!vectorAvailable && isDev)` guard around `vectorUnavailableMessage`. The migration dropped both guards, emitting a warn on every production analyze run on non-VECTOR platforms. P2 — error-shape fixes for pino's err serializer - serve.ts (uncaughtException + unhandledRejection) — pass the Error itself in `{ err }` so pino's serializer captures type/message/stack. Was passing `err.message` (string) which lost the stack and shape. - api.ts:1823 — same fix; was passing `err?.stack || err`. - wiki.ts:587 — was passing the bare Error as the first arg to `logger.error(err)`, which pino coerces via `.toString()` and loses the shape; changed to `logger.error({ err }, 'wiki command failed')`. P2 — design hygiene - core/logger.ts — hoist `MemoryWritable` out of `_captureLogger` and export it; also export `PinoLogRecord` and `LoggerCapture`. Removes the duplicate definition in `logger.test.ts`. - core/logger.ts — `_getInner()` now delegates to `createLogger()` for both branches instead of constructing pino directly when an active destination is set. Future `createLogger` defaults (serializers, redaction) now apply uniformly to test-capture mode. - eslint.config.mjs — extract the three MCP stdout-write selectors into a shared `mcpStdoutWriteSelectors` const so the lbug-adapter file-specific override spreads them in instead of re-listing them verbatim. Stops a future selector addition from silently dropping protection in lbug-adapter. P2 — test coverage - worker-pool.test.ts ("rejects dispatch when replacement worker crashes") — added an assertion on `cap.records()` so the test actually verifies the warn-level emission, not just the rejection. Was capturing pino output and discarding it. - logger.test.ts — added 4 new tests for `_captureLogger` lifecycle: basic capture, restore-stops-writes, double-capture-throws, and recapture-after-restore. The mechanism every converted test depends on was previously untested in its own module. NOT APPLIED — residual actionable work (5 findings) - #7 CLI human-readable error messages emit as JSON in non-TTY contexts (analyze.ts validators, EADDRINUSE banners, OOM/ERESOLVE recovery blocks). Design issue: needs a dedicated `cliMessage()` helper that bypasses pino. Scope is too large for this batch. - #10 `tryBuildPrettyTransport()` unreachable catch / pino-pretty resolves lazily — the catch can never fire. Fix is to probe with `require.resolve('pino-pretty')` inside the try block. Mechanical but changes the safety contract; deferred for review. - #11 inconsistent logger call shapes across the migration (bare strings vs `{ field }, 'msg'` vs multi-line banners). Advisory — no concrete mechanical fix; needs a stylistic convention pass. - #12 `pino.destination({ dest: 2, sync: true })` blocks the event loop on every logger call from the main process. Fix needs `sync: false` + `flushSync()` hooks on `beforeExit`/`SIGTERM`. Non-trivial; deferred. - #17 `pino.final()` not registered in serve.ts crash handlers — async pretty-print path may not flush before `process.exit(1)` on dev TTY. Defer; bounded to dev TTY scenarios. Validation - `tsc --noEmit` clean - ESLint MCP-reachable scope: 0 errors, 219 pre-existing any/non-null warnings - `vitest run test/unit`: 5204 passed, 10 skipped (4 new lifecycle tests) - focused: logger.test.ts 26/26, worker-pool.test.ts 22/22, grpc-extractor 39/39 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(logger): harden runtime — pino-pretty packaging, sync writes, CLI UX Implements the 5 logger-runtime findings from the multi-agent code review and Codex's adversarial review (plan: docs/plans/2026-05-07-001-fix-pino-logger-runtime-hardening-plan.md). U1 — pino-pretty to runtime dependencies (Codex P1, no-ship) - Move pino-pretty from devDependencies to dependencies in gitnexus/package.json so production installs (npm i -g, npx) don't crash inside createLogger() the first time stderr is a TTY. - Lockfile regenerated; npm ls --omit=dev confirms placement. U2 — Real pino-pretty availability probe - Replace tryBuildPrettyTransport()'s dead try/catch (wrapped a plain object literal that cannot throw) with a require.resolve('pino-pretty') probe via createRequire. Memoize via _prettyAvailable cache. - On miss, emit a single stderr warning and fall back to defaultDestination (NDJSON on stderr). Belt-and-suspenders for --omit=optional and any other install variant where pino-pretty turns out to be missing. - Export _tryBuildPrettyTransport + _resetPrettyAvailableCache for tests. - Add 3 unit tests covering happy path, memoization, and warning bound. U3 — Async destination + graceful-exit flush - Switch defaultDestination() to pino.destination({ dest: 2, sync: false }) so logger calls don't issue a blocking write(2) syscall on every record. - Cache the destination in module-level _dest. Register process.on( 'beforeExit', flushSync) once at module load (gated on !VITEST so vitest's between-test cleanup doesn't fight _captureLogger). - Export flushLoggerSync() helper. Wire into existing shutdown handlers in cli/analyze.ts (SIGINT) and mcp/server.ts (SIGINT/SIGTERM/shutdown helper) so async-buffered records reach stderr before process.exit. - Add smoke test for flushLoggerSync's no-op-on-empty-state contract. U4 — Crash flush in serve.ts and api.ts - Add flushLoggerSync() between logger.error and process.exit(1) in serve.ts uncaughtException/unhandledRejection handlers and api.ts uncaughtException handler. - Pino v10 removed pino.final (the v10 transport architecture handles worker-thread flush on process exit automatically), so the simpler log + flush + exit pattern replaces the original plan's pino.final integration. Captured in the commented logger.ts JSDoc. - api.ts shutdown() also flushes before process.exit(0). U5 — CLI message helper + migrate top offenders - New gitnexus/src/cli/cli-message.ts exporting cliInfo/cliWarn/cliError. Each writes plain text to process.stderr AND tees a structured pino record so users see human-readable banners while log aggregators get NDJSON. Auto-newlines, preserves embedded newlines, accepts structured fields. - Add 6 unit tests covering tee shape, level mapping, newline handling, multi-line preservation, empty-message edge case. - Migrate top user-facing offenders identified in review: - cli/analyze.ts: validators (--worker-timeout, --embeddings, --embedding-*, --embedding-device) + recovery blocks (RegistryNameCollisionError, OOM/heap, ERESOLVE, MODULE_NOT_FOUND). Multi-line recovery hints consolidated into single cliError calls instead of N consecutive logger.error('') lines that emitted N empty NDJSON records. - cli/serve.ts: EADDRINUSE banner + Failed-to-start error. - cli/eval-server.ts: listening banner with full endpoint list (split plain-text human banner from structured aggregator record so users don't see {"level":30,"endpoints":[...]} in their terminal). - Update analyze-embeddings-limit.test.ts to spy on process.stderr.write instead of console.error (the validator now bypasses console). Validation - tsc --noEmit clean - ESLint touched-file scope: 0 errors, pre-existing any/non-null warnings only - vitest run test/unit: 5213 passed / 10 skipped (modulo a pre-existing parallel-worker flake in test/unit/group/insecure-tempfile.test.ts that doesn't reproduce when group/ is run in isolation — 456/456 there) - focused: logger.test.ts 19/19, cli-message.test.ts 6/6, analyze-embeddings-limit.test.ts 9/9 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(cli): route hard-exit diagnostics through cliError to defeat buffer drain race Codex's adversarial review on PR #1336 flagged that nine `logger.error/warn` + `process.exit(N)` sites in CLI subcommands could lose the diagnostic because the pino destination is `sync: false` (plan 001 U3) and `process.exit` skips the `beforeExit` flush hook. Symptom: a non-zero exit with no visible message. U1: migrate the nine sites to `cliError`/`cliWarn` - gitnexus/src/cli/tool.ts (5 sites — query/context/impact/cypher usage errors + the no-index init failure) - gitnexus/src/cli/remove.ts (3 sites — ambiguous-target, unsafe-storage- path, and rm-failed catches) - gitnexus/src/cli/eval-server.ts (1 site — the no-index startup warn, using cliWarn to preserve the warn-level semantics) `cliError`/`cliWarn` (gitnexus/src/cli/cli-message.ts, plan 001 U5) write plain text directly to process.stderr AND tee a structured pino record. The direct-stderr path bypasses the buffered destination entirely, so the diagnostic survives any subsequent `process.exit` regardless of buffer state. Removed the now-unused `import { logger }` from tool.ts (lint caught it). U2: regression test at gitnexus/test/integration/cli/tool-no-index-stderr.test.ts - Spawns `node dist/cli/index.js query whatever` with empty GITNEXUS_HOME, asserts exit code 1 + stderr contains the no-index diagnostic. Pattern mirrors test/integration/mcp/server-startup.test.ts. Honesty caveat: the regression signal is not deterministic. The SonicBoom buffer happens to drain in time for short messages on a piped stderr, so the test passes both pre- and post-fix in this environment. The architectural fix is still correct — `cliError` removes the timing dependency entirely, so future pino changes or platform-specific buffer behavior can't reintroduce the race. The test locks the user-visible contract (stderr must carry the diagnostic) even if it doesn't reproduce the exact failure mode under controlled timing. Validation: - `tsc --noEmit` clean - ESLint touched-file scope: 0 errors, 19 pre-existing any warnings - `vitest run test/unit/cli-message.test.ts test/unit/logger.test.ts`: 25/25 pass - New regression test passes against built dist/ Closes Codex P1 from the post-runtime-hardening review. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ci): replace console.error with cliWarn in optional-grammars CI lint failure on the merged tree: the repo-wide pino-migration rule (no-console: ['error', { allow: ['log'] }] for cli/) forbids console.error in CLI code. optional-grammars.ts was added by PR #1383 and used console.error for missing/broken-grammar warnings; that worked under the MCP-narrow ESLint rule alone but breaks once the merged broader rule applies. Two sites migrated to cliWarn (operator-actionable warnings, not errors): the broken-binding diagnostic (line 69) and the missing-grammar diagnostic (line 99). Each now writes plain text to stderr AND tees a structured logger.warn record with grammar/extensions/error fields. Also: hoisted opts?.relevantExtensions into a local const so the closure inside .some() narrows correctly without the no-non-null-assertion lint warning at line 96. Validation - ESLint optional-grammars.ts: 0 errors, 0 warnings (was 2 errors + 1 warning) - tsc --noEmit clean - vitest run cli-message + logger: 25/25 pass Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- eslint.config.mjs | 85 ++-- gitnexus/package-lock.json | 246 ++++++++++++ gitnexus/package.json | 2 + gitnexus/src/cli/ai-context.ts | 3 +- gitnexus/src/cli/analyze.ts | 91 +++-- gitnexus/src/cli/clean.ts | 7 +- gitnexus/src/cli/cli-message.ts | 65 +++ gitnexus/src/cli/eval-server.ts | 52 ++- gitnexus/src/cli/group.ts | 7 +- gitnexus/src/cli/mcp.ts | 21 +- gitnexus/src/cli/optional-grammars.ts | 19 +- gitnexus/src/cli/remove.ts | 11 +- gitnexus/src/cli/serve.ts | 46 ++- gitnexus/src/cli/tool.ts | 11 +- gitnexus/src/cli/wiki.ts | 3 +- gitnexus/src/config/ignore-service.ts | 3 +- gitnexus/src/core/embeddings/embedder.ts | 17 +- .../src/core/embeddings/embedding-pipeline.ts | 31 +- gitnexus/src/core/group/bridge-db.ts | 15 +- .../extractors/elixir-workspace-extractor.ts | 3 +- .../extractors/go-workspace-extractor.ts | 3 +- .../core/group/extractors/grpc-extractor.ts | 3 +- .../extractors/java-workspace-extractor.ts | 3 +- .../group/extractors/manifest-extractor.ts | 3 +- .../extractors/node-workspace-extractor.ts | 3 +- .../extractors/python-workspace-extractor.ts | 3 +- .../extractors/rust-workspace-extractor.ts | 3 +- gitnexus/src/core/group/service.ts | 9 +- gitnexus/src/core/group/sync.ts | 7 +- gitnexus/src/core/ingestion/ast-cache.ts | 3 +- gitnexus/src/core/ingestion/call-processor.ts | 5 +- .../src/core/ingestion/cluster-enricher.ts | 5 +- .../ingestion/cobol/cobol-copy-expander.ts | 7 +- .../src/core/ingestion/filesystem-walker.ts | 5 +- .../src/core/ingestion/heritage-processor.ts | 5 +- .../src/core/ingestion/import-processor.ts | 29 +- .../src/core/ingestion/language-config.ts | 11 +- .../ingestion/method-extractors/generic.ts | 3 +- .../src/core/ingestion/parsing-processor.ts | 13 +- .../core/ingestion/pipeline-phases/cobol.ts | 7 +- .../ingestion/pipeline-phases/communities.ts | 3 +- .../pipeline-phases/cross-file-impl.ts | 9 +- .../ingestion/pipeline-phases/cross-file.ts | 5 +- .../ingestion/pipeline-phases/markdown.ts | 3 +- .../src/core/ingestion/pipeline-phases/mro.ts | 3 +- .../src/core/ingestion/pipeline-phases/orm.ts | 3 +- .../ingestion/pipeline-phases/parse-impl.ts | 21 +- .../ingestion/pipeline-phases/processes.ts | 5 +- .../core/ingestion/pipeline-phases/routes.ts | 7 +- .../core/ingestion/pipeline-phases/runner.ts | 5 +- .../core/ingestion/pipeline-phases/tools.ts | 3 +- .../src/core/ingestion/process-processor.ts | 7 +- .../core/ingestion/scope-extractor-bridge.ts | 3 +- .../scope-resolution/pipeline/phase.ts | 5 +- .../scope-resolution/pipeline/run.ts | 3 +- gitnexus/src/core/ingestion/type-env.ts | 3 +- .../src/core/ingestion/utils/max-file-size.ts | 3 +- .../core/ingestion/workers/parse-worker.ts | 9 +- .../src/core/ingestion/workers/worker-pool.ts | 33 +- gitnexus/src/core/lbug/extension-loader.ts | 3 +- gitnexus/src/core/lbug/lbug-adapter.ts | 13 +- gitnexus/src/core/logger.ts | 375 ++++++++++++++++++ .../src/core/tree-sitter/parser-loader.ts | 13 +- gitnexus/src/core/wiki/cursor-client.ts | 3 +- gitnexus/src/core/wiki/llm-client.ts | 3 +- gitnexus/src/mcp/core/embedder.ts | 5 +- gitnexus/src/mcp/local/local-backend.ts | 38 +- gitnexus/src/mcp/server.ts | 8 +- gitnexus/src/server/api.ts | 34 +- gitnexus/src/server/git-clone.ts | 3 +- gitnexus/src/server/mcp-http.ts | 3 +- .../cli/tool-no-index-stderr.test.ts | 122 ++++++ .../integration/filesystem-walker.test.ts | 23 +- gitnexus/test/integration/worker-pool.test.ts | 50 ++- .../unit/analyze-embeddings-limit.test.ts | 16 +- .../test/unit/analyze-worker-timeout.test.ts | 11 +- gitnexus/test/unit/calltool-dispatch.test.ts | 27 +- gitnexus/test/unit/cli-message.test.ts | 99 +++++ .../test/unit/group/grpc-extractor.test.ts | 29 +- .../group/rust-workspace-extractor.test.ts | 13 +- gitnexus/test/unit/group/sync.test.ts | 9 +- gitnexus/test/unit/ignore-service.test.ts | 9 +- gitnexus/test/unit/logger.test.ts | 279 +++++++++++++ gitnexus/test/unit/max-file-size.test.ts | 43 +- .../sequential-language-availability.test.ts | 65 ++- gitnexus/test/unit/web-ui-serving.test.ts | 18 +- 86 files changed, 1896 insertions(+), 419 deletions(-) create mode 100644 gitnexus/src/cli/cli-message.ts create mode 100644 gitnexus/src/core/logger.ts create mode 100644 gitnexus/test/integration/cli/tool-no-index-stderr.test.ts create mode 100644 gitnexus/test/unit/cli-message.test.ts create mode 100644 gitnexus/test/unit/logger.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 910025683..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,26 @@ 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', }, }, @@ -84,32 +131,7 @@ export default [ ], rules: { 'no-console': ['error', { allow: ['error'] }], - 'no-restricted-syntax': [ - 'error', - { - selector: - "MemberExpression[object.type='MemberExpression'][object.object.name='process'][object.property.name='stdout'][property.name='write']", - message: - 'Direct process.stdout.write is forbidden in MCP-reachable code. Route diagnostics through console.error or process.stderr.write — the MCP stdio transport owns stdout for JSON-RPC frames.', - }, - { - selector: - "CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.object.name='process'][callee.object.property.name='stdout'][callee.property.name='write']", - message: - 'Direct process.stdout.write is forbidden in MCP-reachable code. Route diagnostics through console.error or process.stderr.write — the MCP stdio transport owns stdout for JSON-RPC frames.', - }, - { - // Catches the canonical destructuring shape: - // const { write } = process.stdout; - // (and any other ObjectPattern destructure rooted at process.stdout) - // which would otherwise capture a reference to the original write - // and bypass the sentinel. - selector: - "VariableDeclarator[init.type='MemberExpression'][init.object.name='process'][init.property.name='stdout'] > ObjectPattern", - message: - 'Destructuring process.stdout is forbidden in MCP-reachable code — bypasses the sentinel. Use process.stderr.write for diagnostics.', - }, - ], + 'no-restricted-syntax': ['error', ...mcpStdoutWriteSelectors], }, }, @@ -129,11 +151,18 @@ export default [ // 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).', diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index ccc61bc5b..f4fd4f2d9 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", @@ -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", @@ -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", @@ -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 6aa5f59b7..84f702762 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -73,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/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 90b8c9a43..175db47e6 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -26,6 +26,7 @@ import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-si 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 @@ -167,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; } @@ -184,7 +185,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption if (typeof options?.embeddings === 'string') { const parsed = Number(options.embeddings); if (!Number.isInteger(parsed) || parsed < 0) { - console.error( + 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`, ); @@ -203,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; } @@ -234,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; } @@ -330,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); @@ -339,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[]) => { @@ -354,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 @@ -420,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'); @@ -493,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' }); @@ -518,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(); @@ -527,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; } @@ -574,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/mcp.ts b/gitnexus/src/cli/mcp.ts index e69ce0eb0..056b12591 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -41,11 +41,17 @@ export const mcpCommand = async () => { // path runs cleanly with full stack traces. Registering duplicates here // would only produce noisy double-logging on the same exception. - // Now safe to dynamically import the heavy backend modules. Anything - // they emit to stdout during evaluation will route through the sentinel. - const [{ startMCPServer }, { LocalBackend }] = await Promise.all([ + // 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. @@ -62,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 index c6e239b56..14f6c3c5e 100644 --- a/gitnexus/src/cli/optional-grammars.ts +++ b/gitnexus/src/cli/optional-grammars.ts @@ -13,6 +13,7 @@ */ import { createRequire } from 'module'; +import { cliWarn } from './cli-message.js'; const _require = createRequire(import.meta.url); @@ -65,9 +66,14 @@ export function detectMissingOptionalGrammars(): MissingGrammar[] { /could not find|no native build|prebuilds/i.test(msg); if (!looksMissing) { // Present but broken — surface so the user doesn't get a misleading - // "reinstall" recovery message that wouldn't actually help. - console.error( + // "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 }); @@ -92,12 +98,17 @@ export function warnMissingOptionalGrammars(opts?: { 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 (opts?.relevantExtensions && !g.extensions.some((e) => opts.relevantExtensions!.has(e))) { + if (relevantExtensions && !g.extensions.some((e) => relevantExtensions.has(e))) { continue; } - console.error( + 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/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/embeddings/embedder.ts b/gitnexus/src/core/embeddings/embedder.ts index 993c41883..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.error(`🧠 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.error('🔧 Trying DirectML (DirectX12) GPU backend...'); + logger.info('🔧 Trying DirectML (DirectX12) GPU backend...'); } else if (isDev && device === 'cuda') { - console.error('🔧 Trying CUDA GPU backend...'); + logger.info('🔧 Trying CUDA GPU backend...'); } else if (isDev && device === 'cpu') { - console.error('🔧 Using CPU backend...'); + logger.info('🔧 Using CPU backend...'); } else if (isDev && device === 'wasm') { - console.error('🔧 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.error(`✅ Using ${label} backend`); - console.error('✅ 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.error(`⚠️ ${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 9d4f98281..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.error(`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.error('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.error(vectorUnavailableMessage); + if (!vectorAvailable && isDev) { + logger.warn(vectorUnavailableMessage); + } // Phase 1: Load embedding model onProgress({ @@ -283,7 +286,7 @@ export const runEmbeddingPipeline = async ( }); if (isDev) { - console.error('🔍 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.error(`🔄 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.error( + 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.error(`📊 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.error( + 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.error('📇 Creating vector index...'); + logger.info('📇 Creating vector index...'); } const vectorIndexReady = await createVectorIndex(executeQuery); @@ -533,7 +536,7 @@ export const runEmbeddingPipeline = async ( }); if (isDev) { - console.error( + 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 ef45d6819..dbf2350bb 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -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. @@ -702,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/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 0c3cd20ca..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[]; @@ -303,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}`, ); 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/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/sync.ts b/gitnexus/src/core/group/sync.ts index 9a77df22a..7ed065131 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -16,6 +16,7 @@ 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) @@ -211,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`, ); } @@ -230,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`, ); } @@ -241,7 +242,7 @@ 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)`, ); } 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/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 d06b2b38c..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 @@ -297,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); @@ -310,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, @@ -402,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 b925c5f88..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.error; + 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 a4559436a..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) // --------------------------------------------------------------------------- @@ -330,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.error(`[gitnexus:lbug] schema creation warning: ${msg.slice(0, 120)}`); + logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`); } } } @@ -683,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; } }; @@ -1010,15 +1011,15 @@ export const fetchExistingEmbeddingHashes = async ( const nodeId = r.nodeId ?? r[0]; if (nodeId) map.set(nodeId, STALE_HASH_SENTINEL); } - console.error( - `[gitnexus:embed] ${map.size} nodes in legacy DB (missing chunk-aware columns) — all treated as stale`, + 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.error( - `[gitnexus:embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`, + logger.info( + `[embed] CodeEmbedding table not yet present — full embedding run (${fallbackMsg})`, ); return undefined; } 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/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index 0ec958e36..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,10 +176,14 @@ const logFailure = (key: string, result: LoadResult): void => { logged.add(key); const message = `[gitnexus] ${result.note} (${result.error.message})`; - // Both severities go to stderr — console.warn writes to stderr too, but - // console.error is the stdout-safe channel we standardize on across - // MCP-reachable code so the ESLint rule covers this directory. - console.error(message); + // 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/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/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 f7193c484..5159b12d9 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -312,7 +312,11 @@ export async function startMCPServer(backend: LocalBackend): Promise { // stray writes even when individual payloads were truncated/suppressed. process.on('exit', () => sentinel.flushSummary()); - // Graceful shutdown helper + // 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; @@ -323,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/server/api.ts b/gitnexus/src/server/api.ts index a8f784630..773190785 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -27,8 +27,6 @@ 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 { mountMCPEndpoints } from './mcp-http.js'; import { fork } from 'child_process'; @@ -36,6 +34,7 @@ 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'); @@ -143,7 +142,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}:`); } } } @@ -1490,7 +1489,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.', @@ -1522,7 +1521,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}` : ''), ); @@ -1790,7 +1789,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' }); }); @@ -1804,7 +1803,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(); @@ -1813,22 +1814,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/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/worker-pool.test.ts b/gitnexus/test/integration/worker-pool.test.ts index b6e3a023c..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,7 +299,7 @@ describe('worker pool integration', () => { `, ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const cap = _captureLogger(); pool = createWorkerPool(pathToFileURL(workerPath) as URL, 1, { subBatchIdleTimeoutMs: 500, maxTimeoutRetries: 1, @@ -308,9 +309,12 @@ 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 2s 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 }); } }); @@ -337,7 +341,11 @@ describe('worker pool integration', () => { `, ); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // 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, @@ -348,8 +356,10 @@ describe('worker pool integration', () => { 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 { - warnSpy.mockRestore(); + cap.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); } }); @@ -383,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, @@ -411,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 }); } }); @@ -479,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, @@ -505,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 }); } }); @@ -543,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, @@ -562,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 index 93978ad9d..6fbc5af56 100644 --- a/gitnexus/test/unit/analyze-embeddings-limit.test.ts +++ b/gitnexus/test/unit/analyze-embeddings-limit.test.ts @@ -44,17 +44,23 @@ describe('analyzeCommand --embeddings [limit] parsing', () => { it.each(['abc', '-1', '1.5', 'NaN', 'Infinity'])( 'rejects invalid --embeddings value %s before analysis starts', async (embeddings) => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + // 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 msg = errorSpy.mock.calls[0]?.[0] ?? ''; - expect(msg).toContain('--embeddings expects a non-negative integer'); - expect(msg).toContain(`got "${embeddings}"`); - errorSpy.mockRestore(); + 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(); }, ); 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/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/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 17bd1c45d..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, @@ -650,9 +651,7 @@ service OrderService { 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 () => [], @@ -664,9 +663,9 @@ service OrderService { 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(); } }); 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/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/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/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 () => { From 0824b96d150b48c745dc2b8f0ecc701dd37c33ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 21:41:00 +0100 Subject: [PATCH 29/29] chore(deps)(deps-dev): bump @types/node in /gitnexus (#1421) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.6.0 to 25.6.1. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.6.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index f4fd4f2d9..b07ccd7cb 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -2065,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"